From bb82f9612cdbfb2ac6929167f041061e9e3d4a0a Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sun, 23 Aug 2026 13:58:45 -0400 Subject: [PATCH] fix(cmdatom): "!" operator hardcodes its range #41451 Problem: The "!" operator stuffs its cmdline continuation (`:.,.+1!`), so its frame ends before capture (stuff pending) and the redo-prep disappears with it. !ipsort => { type='excmd', lhs=':.,.+1!sort', keys=':.,.+1!sort' } Compare to builtin "." which works bc `do_bang()` completes the redo (`!ip` + `sort`). Solution: Appoint the stuffed continuation frame as the "redo-prep" frame. !ipsort => { type='operator', operator='!', lhs='!ipsort', keys='!ipsort' } Notes: - atom_cmd_end(): a frame ending with stuff pending re-points its redo-prep to the next frame. - atom_cmd_start(): a stuffed continuation frame (`KeyStuffed`) keeps the redo-prep; flushed stuff discards it. --- runtime/doc/autocmd.txt | 3 ++ runtime/doc/repeat.txt | 70 +++++++++++++++++++++---- src/nvim/input.c | 10 ++-- src/nvim/input_cmdatom.c | 16 ++++-- src/nvim/input_cmdatom.h | 2 +- test/functional/editor/cmdatom_spec.lua | 44 +++++++++++++++- 6 files changed, 123 insertions(+), 22 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index adbe7e874e..4ef0d5825d 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -448,6 +448,9 @@ CmdAtom After a user action (an "atom" of input): any Note: all keys fed during a mapping execution are part of the mapping's resolved `keys`. + Plugins should not publish "fake" CmdAtom + events via `nvim_exec_autocmds`. + The |event-data| has these fields (type: `vim.event.cmdatom.data`): - atoms: Ordered "subatoms" of a composite diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index c938c4d085..a525dbe5d6 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -75,18 +75,17 @@ To repeat an atom, feed `keys` (mode "n"), or `lhs` (mode "m") if `keys=nil`. >l end < *motion-repeat* -Example: This "," mapping repeats the last "motion" ("zj", "3w", "fx", -"/pat", "]c"). Drop the `pattern` to repeat any non-edit action, motion or -not ("zz", "zfa{", CTRL-W_w): >lua +Example: This "," mapping repeats the last motion ("zj", "3w", "fx", +"/pat", "]c", any mapping/command that sets `moved=true`). >lua local last ---@type vim.event.cmdatom.data? vim.api.nvim_create_autocmd('CmdAtom', { - pattern = 'motion', callback = function(ev) - if ev.data.changed or ev.data.lhs == ',' then - return -- Skip edits, and this mapping itself. + -- Skip edits, and the "," mapping itself. + local motion = ev.data.moved or ev.match == 'motion' + if motion and not (ev.data.changed or ev.data.lhs == ',') then + last = ev.data end - last = ev.data end, }) vim.keymap.set('n', ',', function() @@ -98,14 +97,18 @@ not ("zz", "zfa{", CTRL-W_w): >lua end) end) < - *dot-repeat* -Example: This "." mapping repeats ANY edit, including operations provided by -plugins, without the need for "announcement" via vim-repeat or similar. >lua + *edit-repeat* +Example: This "." mapping repeats ANY edit (except undo/redo), including +operations provided by plugins, without the need for "announcement" via +vim-repeat or similar. >lua local last ---@type vim.event.cmdatom.data? + local maxseq = {} ---@type table vim.api.nvim_create_autocmd('CmdAtom', { callback = function(ev) - if ev.data.changed and ev.data.lhs ~= '.' then + local is_redo_or_undo = ev.data.changed and (ev.data.undoseq or 0) <= (maxseq[ev.buf] or 0) + maxseq[ev.buf] = vim.fn.undotree(ev.buf).seq_last + if ev.data.changed and not is_redo_or_undo and ev.data.lhs ~= '.' then last = ev.data end end, @@ -118,6 +121,51 @@ plugins, without the need for "announcement" via vim-repeat or similar. >lua end end) end) +< + *cmdatom-macro* +Example: This "[count]" mapping saves the last [count] atoms as an +editable macro: "2" opens the |cmdwin| with the last [count] atoms +listed (you can delete/edit steps if you want), saves it. "" +without a count replays the macro. >lua + + -- Track the last 20 atoms. + local atom_ring = {} ---@type vim.event.cmdatom.data[] + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + -- Skip this mapping itself, and cmdwin edits. + if ev.data.lhs ~= ' ' and vim.fn.getcmdwintype() == '' then + atom_ring[#atom_ring + 1] = ev.data + if #atom_ring > 20 then + table.remove(atom_ring, 1) + end + end + end, + }) + -- [count] shows a cmdwin where the user can edit/save the last [count] atoms as a "macro". + -- (no count) replays it. + vim.keymap.set('n', '', function() + local count = vim.v.count + -- CmdAtom is deferred; schedule it so pending events land in the ring first. + vim.schedule(function() + count = math.min(count, #atom_ring) + if count == 0 then -- Replay the saved macro. + for _, step in ipairs(vim.g.atom_macro or {}) do + vim.api.nvim_feedkeys(vim.keycode(step.keys or step.lhs), step.keys and 'n' or 'm', false) + end + return + end + local parts = {} + for i = #atom_ring - count + 1, #atom_ring do + local a = atom_ring[i] + local keys = a.keys or ('%s%s'):format(a.count or '', a.lhs) + local field = a.keys and 'keys' or 'lhs' + parts[#parts + 1] = ('{%s=%q},'):format(field, vim.fn.keytrans(keys)) + end + local cmd = ('lua vim.g.atom_macro = { %s }'):format(table.concat(parts, ' ')) + -- Draft it on the cmdline; CTRL-F opens the cmdwin to edit it. + vim.api.nvim_feedkeys((':%s%s'):format(cmd, vim.keycode('')), 'n', false) + end) + end) < *restore-undo-cursor* Example: Restore cursor position after undo. Works for |u|, "3u", |CTRL-R|, diff --git a/src/nvim/input.c b/src/nvim/input.c index 43af7d4f8b..ece5da2b65 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -557,13 +557,13 @@ void redo_free_all(void) /// Prepare for redo of any command: stores `spec` and appends its command chars. /// -/// @param claim Claim it as the atom. False if the atom is captured by other means -/// (insert-session entry/restart, "z="). +/// @param as_atom The redo also defines the command's atom (`curcmd.redo_frame`). False for +/// "prep-exempt" special cases (insert-session entry/restart, "z="). /// @param arg_meta Skip the `arg` byte: an interactively-typed operand may need CTRL-V quoting /// or its composing-char string form, which the caller appends itself. -void prep_redo(bool claim, bool arg_meta, CmdSpec spec) +void prep_redo(bool as_atom, bool arg_meta, CmdSpec spec) { - if (claim) { + if (as_atom) { atom_redo_set(spec); } redo_new(spec); @@ -576,7 +576,7 @@ void prep_redo(bool claim, bool arg_meta, CmdSpec spec) /// Prepare for redo of a Visual-mode command: the body opens with `keys` (the captured selection), /// so "." re-executes the selection at cursor; the `["x][count]` prefix and command chars of /// `spec` compose into the body after them (zeroed in the stored spec, so replay doesn't also -/// prefix them). Always claims (see prep_redo()). +/// prefix them). void prep_redo_visual(const char *keys, size_t len, CmdSpec spec) { CmdSpec stored = spec; diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 0340356b19..90d1401ca6 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -112,7 +112,7 @@ static struct { /// Per-command capture scratch. static struct { - uint64_t redo_frame; ///< CmdFrame that prepped (prep_redo*()); frame end captures it. 0: none. + uint64_t redo_frame; ///< The CmdFrame that prepped redo (prep_redo*()). 0: none. char *cmdline; ///< The ":" payload captured at cmdline accept. NULL: none. ///< Note: search payloads ("/pat") travel on `cmdarg.searchbuf`. bool ins_cascaded; ///< Did the command's insert-session already cascade? @@ -816,8 +816,8 @@ void atom_op_global_set(void) curcmd.op_global = true; } -/// Claims the prepped redo as the command's atom (prep_redo()). Only toplevel user commands (a -/// nested redo-prep is not an atom). Declines Lua operators. +/// Sets `curcmd.redo_frame`: at frame end, the redobuf defines `CmdAtom.keys`. +/// Not for nested frames (":norm"), nor Lua operators. void atom_redo_set(CmdSpec spec) { if (spec.cmd == K_LUA) { @@ -1206,7 +1206,10 @@ void atom_cmd_start(CmdFrame *old) old->parent = cur_frame; cur_frame = old; curcmd.op_global = false; - atom_redo_reset(); + // A stuffed continuation frame keeps the redo-prep. "!ipsort" spans both frames. + if (!(KeyStuffed && curcmd.redo_frame == old->id)) { + atom_redo_reset(); + } } /// Captures the typed command's atom: one atom per command, produced from the CmdFrame diff and @@ -1291,6 +1294,11 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) // // Capture: does this command own an atom? // + if (!stuff_empty() && curcmd.redo_frame == old->id && !Visual.active && !old->visual.active) { + // The stuffed continuation completes the redo (op_filter/do_bang()), and is the next frame + // (stuff precedes typeahead). Flushed instead? atom_cmd_start() checks KeyStuffed. + curcmd.redo_frame = old->id + 1; + } if ((vis && atom_captures == old->captures && ca->oap->op_type == OP_NOP) || (!Visual.active && !old->visual.active diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index 0f09857c95..4bfb077dc2 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -10,7 +10,7 @@ #include "nvim/pos_defs.h" #include "nvim/register_defs.h" // Timestamp -/// Pending atom(s). Multiple atoms may queue; they cascade as a batch (mc_clock_edge). +/// Multicursor: pending atoms; they cascade as a batch (mc_clock_edge). extern CmdAtomVec g_atoms; /// Pre-command state sampled at entry + storage for its "staged" atom. atom_cmd_end() finalizes it. diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index bf3fe5e74c..38eab24357 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -250,11 +250,12 @@ describe('CmdAtom', function() eq('motion', n.exec_lua('return _G.last_match')) end) - it('captures counts and payload chars', function() + it('captures counts, payload', function() fn.setline(1, { 'abcd,ef' }) feed('gg0') atoms_start() feed('yw') + -- A yank emits but does not edit. eq({ operator = 'y', changed = false }, pick(atom_last(), 'operator', 'changed')) feed('3x') @@ -262,12 +263,14 @@ describe('CmdAtom', function() feed('vf,d') eq({ 'ef' }, get_lines()) eq({ '3dl', 'vf,d' }, atoms_tail(2)) + -- Structured decomposition: operator/motion/operand as fields, no byte-parsing. local op = atoms()[#atoms() - 1] -- "3dl" eq( { operator = 'd', cmd = 'l', count = 3, changed = true }, pick(op, 'operator', 'cmd', 'cmdarg', 'count', 'motionforce', 'changed') ) + -- A visual atom carries the completing operator's fields, and decomposes -- into its commands ("v", "f," and the operator). local vis = atom_last() -- "vf,d" @@ -283,6 +286,7 @@ describe('CmdAtom', function() end, vis.atoms) ) eq('d', vis.atoms[3].operator) + -- Operators with an interactively-typed search payload: the atom is the -- redobuff, which includes the payload. fn.setline(1, { 'aa END bb' }) @@ -294,6 +298,7 @@ describe('CmdAtom', function() { operator = 'd', cmd = '/', changed = true }, pick(atom_last(), 'operator', 'cmd', 'changed') ) + -- An operand (mark or register name, target char) is its own field; the second char of a -- two-char command NAME ("gJ") composes into `cmd`. fn.setline(1, { 'one', 'two' }) @@ -305,6 +310,7 @@ describe('CmdAtom', function() feed('qax') -- the recording register is an operand, not part of the name feed('q') eq({ cmd = 'q', cmdarg = 'a' }, pick(atoms()[nrec + 1], 'cmd', 'cmdarg')) + -- Forced motion type ("dvj") is a field. fn.setline(1, { 'one', 'two' }) feed('gg0dvj') @@ -314,6 +320,42 @@ describe('CmdAtom', function() ) end) + it('"!" operator captures its stuffed cmdline continuation', function() + -- ":.,.+1!" + typed "{prg}" completes the operator atom, like "d/END". #41447 + local prg = n.testprg('shell-test') .. ' REP 2 X' + fn.setline(1, { 'b', 'a', '', 'e', 'c', 'd' }) + atoms_start() + feed('gg') + feed(('!ip%s'):format(prg)) + eq({ '0: X', '1: X', '', 'e', 'c', 'd' }, get_lines()) + local bang = atom_last() + local keys = ('!ip%s\n'):format(prg) + eq( + { type = 'operator', operator = '!', lhs = keys, keys = keys }, + pick(bang, 'type', 'operator', 'lhs', 'keys') + ) + -- Replay recomputes the range from the motion: the whole 3-line paragraph is replaced. + feed('4G') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(bang.keys)) + eq({ '0: X', '1: X', '', '0: X', '1: X' }, get_lines()) + -- Same for "=" with 'equalprg', "gq" with 'formatprg'. + api.nvim_set_option_value('equalprg', prg, {}) + api.nvim_set_option_value('formatprg', prg, {}) + api.nvim_buf_set_lines(0, 0, -1, true, { 'b', 'a', '', 'd', 'c' }) + feed('gg=ip') + eq({ '0: X', '1: X', '', 'd', 'c' }, get_lines()) + eq( + { type = 'operator', operator = '=', keys = '=ip' }, + pick(atom_last(), 'type', 'operator', 'keys') + ) + feed('4Ggqip') + eq({ '0: X', '1: X', '', '0: X', '1: X' }, get_lines()) + eq( + { type = 'operator', operator = 'gq', keys = 'gqip' }, + pick(atom_last(), 'type', 'operator', 'keys') + ) + end) + it('fires for user input, not programmatic sources', function() atoms_start() -- Drain deferred CmdAtom events, then return + clear the collected list.