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<CR> => { type='excmd', lhs=':.,.+1!sort<NL>', keys=':.,.+1!sort<NL>' }

Compare to builtin "." which works bc `do_bang()` completes the redo
(`!ip` + `sort<NL>`).

Solution:
Appoint the stuffed continuation frame as the "redo-prep" frame.

    !ipsort<CR> => { type='operator', operator='!', lhs='!ipsort<NL>', keys='!ipsort<NL>' }

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.
This commit is contained in:
Justin M. Keyes
2026-08-23 13:58:45 -04:00
committed by GitHub
parent 354ea3bf2a
commit bb82f9612c
6 changed files with 123 additions and 22 deletions

View File

@@ -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

View File

@@ -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<CR>", "]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<CR>", "]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<integer, integer>
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]<Space>" mapping saves the last [count] atoms as an
editable macro: "2<Space>" opens the |cmdwin| with the last [count] atoms
listed (you can delete/edit steps if you want), <Enter> saves it. "<Space>"
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]<space> shows a cmdwin where the user can edit/save the last [count] atoms as a "macro".
-- <space> (no count) replays it.
vim.keymap.set('n', '<Space>', 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('<C-f>')), 'n', false)
end)
end)
<
*restore-undo-cursor*
Example: Restore cursor position after undo. Works for |u|, "3u", |CTRL-R|,

View File

@@ -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;

View File

@@ -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<CR>") 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<CR>" 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

View File

@@ -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.

View File

@@ -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}<CR>" completes the operator atom, like "d/END<CR>". #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<CR>'):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.