fix(cmdatom): operator with Lua textobject is not type=operator

Problem:
An operator completed by a Lua `:omap` textobject emits
`CmdAtom.type="mapping"` (lhs-only, no keys) instead of `type="operator"`.

Analysis:
`atom_redo_set()` declined K_LUA, though the prepped redo
("op" + K_LUA + id + CR) is exactly what "." replays. A no-edit "g@"
emits nothing at all.

Solution:
- `atom_redo_set`: don't decline K_LUA; the redo route now captures the
  operator atom.
- `atom_capture_cmd`: don't early-return if the frame has prepped redo.
- op_function(): save/restore redobuff when invoking 'operatorfunc',
  like `call_user_func()` does for Vimscript. (Else the Lua callback
  may clobber the prepped "g@" redo / dot-repeat.)

fix #41482

TODO:
- async Lua (timer/vim.schedule) can still clobber the pending dot-repeat...
This commit is contained in:
Justin M. Keyes
2026-08-29 02:03:12 +02:00
parent de83b54ddf
commit bc16be3cd9
5 changed files with 144 additions and 17 deletions

View File

@@ -575,7 +575,7 @@ void redo_free_all(void)
void prep_redo(bool as_atom, bool arg_meta, CmdSpec spec)
{
if (as_atom) {
atom_redo_set(spec);
atom_redo_prepped();
}
redo_new(spec);
if (block_redo) {
@@ -593,7 +593,7 @@ void prep_redo_visual(const char *keys, size_t len, CmdSpec spec)
CmdSpec stored = spec;
stored.regname = 0;
stored.count = 0;
atom_redo_set(stored);
atom_redo_prepped();
redo_new(stored);
if (block_redo) {
return;
@@ -611,6 +611,7 @@ void redo_cancel(void)
return;
}
atom_redo_cancel();
kv_destroy(redobuff.cur.body);
redobuff.cur = redobuff.old;
redobuff.old = (CmdSpec){ 0 };

View File

@@ -115,7 +115,7 @@ static struct {
/// Per-command capture scratch.
static struct {
uint64_t redo_frame; ///< The CmdFrame that prepped redo (prep_redo*()). 0: none.
uint64_t redo_frame; ///< Frame whose redobuf (potentially) defines the atom. 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?
@@ -294,7 +294,7 @@ static String atoms_concat_keys(CmdAtomVec atoms)
/// Renders a (cmd, arg, op) char for CmdAtom: key-notation for special keys, else UTF-8. NUL => "".
static char *atom_key_name(int c)
{
if (c == NUL) {
if (c == NUL || c == K_LUA) {
return xstrdup("");
}
if (IS_SPECIAL(c) || c < ' ') {
@@ -840,19 +840,20 @@ void atom_op_global_set(void)
curcmd.op_global = true;
}
/// 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)
/// Declares that the current frame prepped redo. Not for nested frames (":norm").
void atom_redo_prepped(void)
{
if (spec.cmd == K_LUA) {
atom_redo_reset();
return;
}
if (atom_is_user_cmd()) {
curcmd.redo_frame = cur_frame != NULL ? cur_frame->id : 0;
}
}
/// Redo-prep was canceled (aborted operation).
void atom_redo_cancel(void)
{
curcmd.redo_frame = 0;
}
/// Starts accumulating a composite for a macro's commands, labeled "@x".
void atom_macro_start(int regname)
{
@@ -889,6 +890,10 @@ void atom_map_start(const char *lhs, size_t len, bool peeked)
return;
}
if (atom_composite_active()) {
if (get_real_state() == MODE_OP_PENDING) {
// The pending operator's own composite ("gr" <expr> mapping => "g@") absorbs its operand.
return;
}
// Mapping resolved from another's trailing prefix ("nmap x j," + "nnoremap ,w w"): end the
// pending composite, so each `lhs` owns only the keys it produced.
typed.map_start = kv_size(typed.keys);
@@ -1270,7 +1275,9 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
&& (!Visual.active
|| (equalpos(old->visual.start, Visual.start)
&& old->visual.mode == Visual.mode));
if (opaque && unchanged) {
if (opaque && unchanged
// Operator atom? (non-edit Lua/<Cmd> 'operatorfunc'). #41482
&& curcmd.redo_frame != old->id) {
return;
}
bool ins_cascaded = user && curcmd.ins_cascaded;
@@ -1355,8 +1362,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
// Route: decide the atom type and push it.
//
if (curcmd.redo_frame == old->id && !mouse_cmd) {
// Not for mouse commands (middle-click paste): pasting at every cursor would use
// viewport-dependent positions.
// Redoable edit ("dw", "p", "rX", "g@…"): this frame's redobuf defines the atom.
CmdAtom atom = atom_from_redo(kAOperator);
// The payload ('operatorfunc' getchar()) is not in the captured redo, append it.
atom_payload_append(&atom, old);
@@ -1371,7 +1377,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
}
} else if (ca->searchbuf != NULL && (ca->cmdchar == '/' || ca->cmdchar == '?')
&& !(vis && unchanged)) {
// Payload typed in the cmdline ("/pat<CR>"). Emit-only. Not if pattern was not found.
// Payload typed in search cmdline ("/pat<CR>"). Emit-only. Not if pattern was not found.
CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf);
atom.origin = old->origin;
atom_push(false, &atom);
@@ -1417,8 +1423,8 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
}
}
/// Completes a cmd at normal_execute() exit: captures its atom, pushes its staged one, ends the
/// composite. Pops the frame.
/// Completes a normal_execute(): captures its atom, pushes its staged one, ends the composite.
/// Pops the frame.
void atom_cmd_end(cmdarg_T *ca, CmdFrame *old)
{
atom_capture_cmd(ca, old);

View File

@@ -4557,6 +4557,7 @@ static void nv_replace(cmdarg_T *cap)
// Other characters are done below to avoid problems with things like
// CTRL-V 048 (for edit() this would be R CTRL-V 0 ESC).
if (had_ctrl_v != Ctrl_V && cap->nchar == '\t' && (curbuf->b_p_et || p_sta)) {
atom_stuff_start(cap);
stuffnumReadbuff(cap->count1);
stuffcharReadbuff('R');
stuffcharReadbuff('\t');

View File

@@ -3185,10 +3185,16 @@ static void op_function(const oparg_T *oap)
const bool save_finish_op = finish_op;
finish_op = false;
// Preserve prepped "g@" redo from the callback's own commands.
// Like Vimscript call_user_func() does.
RedoState save_redo;
save_redobuff(&save_redo);
typval_T rettv;
if (callback_call(&p_opfunc, 1, argv, &rettv)) {
tv_clear(&rettv);
}
restore_redobuff(&save_redo);
virtual_op = save_virtual_op;
finish_op = save_finish_op;

View File

@@ -361,6 +361,7 @@ describe('CmdAtom', function()
it('stuffed translations execute within their command (exec_stuffed)', function()
-- A register prefix crosses into the "." replay; an explicit register must not touch the
-- small-delete register.
atoms_start()
fn.setline(1, { 'aaa bbb', 'ccc ddd' })
feed('gg0dw')
eq('aaa ', fn.getreg('-'))
@@ -368,16 +369,30 @@ describe('CmdAtom', function()
eq('ccc ', fn.getreg('z'))
eq('aaa ', fn.getreg('-'))
eq({ 'bbb', 'ddd' }, get_lines())
-- One atom: the stuffed replay collects into the "."-labeled composite.
eq(
{ type = 'operator', lhs = k('"z.'), keys = '"zdw', reg = 'z' },
pick(atom_last(), 'type', 'lhs', 'keys', 'reg')
)
-- i_CTRL-O inside a stuffed translation's insert session ("S" == "cc"): the session resumes
-- after ONE normal command.
api.nvim_buf_set_lines(0, 0, -1, true, { 'xxxx', 'yyyy' })
feed('ggSabc<C-o>0def<Esc>')
eq({ 'defabc', 'yyyy' }, get_lines())
-- i_CTRL-O resolution is lossy: keys=nil, replay the "S"-labeled `lhs` instead.
eq(
{ type = 'mapping', lhs = k('Sabc<C-o>0def<Esc>') },
pick(atom_last(), 'type', 'lhs', 'keys')
)
-- r<Tab> under 'expandtab'/'smarttab' ("{count}R<Tab><Esc>": edit() does the tab).
command('set expandtab shiftwidth=4 tabstop=4')
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh', 'ABCDEFGH' })
feed('gg03r<Tab>')
eq(' defgh', fn.getline(1))
eq(
{ type = 'insert', lhs = k('3r<Tab>'), keys = k('3R<Tab><Esc>') },
pick(atom_last(), 'type', 'lhs', 'keys')
)
feed('j0.')
eq(' DEFGH', fn.getline(2))
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh' })
@@ -393,6 +408,7 @@ describe('CmdAtom', function()
command('1s/blue/red/')
feed('2G&3G2&')
eq({ 'red a', 'red b', 'red c', 'red d' }, get_lines())
eq({ type = 'excmd', lhs = '2&', keys = ':.,.+1s\n' }, pick(atom_last(), 'type', 'lhs', 'keys'))
end)
it('mapping that enters :terminal mode', function()
@@ -1110,6 +1126,103 @@ describe('CmdAtom', function()
{ type = 'motion', keys = 'f(', lhs = 'f(' },
{ type = 'excmd', keys = ':call DelSurround()\n)', lhs = 'ds)' },
}, atoms_tail(2, 'type', 'keys', 'lhs'))
-- Same, mid-op ("t(" + "ds" in one batch): the next mapping is not the pending op's operand.
command('nnoremap ,D d')
api.nvim_buf_set_lines(0, 0, -1, true, { 'a x(one)' })
feed('gg0')
n.poke_eventloop()
feed(',Dt(ds)')
eq({ 'one' }, get_lines())
eq({
{ type = 'operator', keys = 'dt(', lhs = ',Dt(' },
{ type = 'excmd', keys = ':call DelSurround()\n)', lhs = 'ds)' },
}, atoms_tail(2, 'type', 'keys', 'lhs'))
end)
it('operator completed by a Lua textobject (:omap) #41482', function()
-- Lua :omap textobject (starts Visual mode, |omap-info|) captures the operator: `keys` is the
-- redobuff (K_LUA + luaref), replayable like "." repeating it. `cmd` is omitted (a Lua
-- callback has no notation).
n.exec_lua([[
vim.keymap.set('o', 'gt', function()
vim.cmd('normal! viw')
end)
]])
fn.setline(1, { 'aaa xxx', 'bbb yyy', 'ccc zzz' })
atoms_start()
feed('gg0wdgt')
eq('aaa ', fn.getline(1))
local ev = atom_last()
eq(
{ type = 'operator', operator = 'd', lhs = 'dgt', changed = true },
pick(ev, 'type', 'operator', 'lhs', 'cmd', 'changed')
)
t.matches('^d\128', ev.keys) -- "d" + K_LUA…: the redobuf.
feed('j0w')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq('bbb ', fn.getline(2))
-- "." repeats it, captured as one "."-labeled operator atom.
feed('j0w.')
eq('ccc ', fn.getline(3))
eq(
{ type = 'operator', operator = 'd', lhs = '.' },
pick(atom_last(), 'type', 'operator', 'lhs')
)
-- 'operatorfunc' + Lua textobject: "ghgt". Non-edit, still emits its operator atom.
n.exec_lua([[
_G.oplog = {}
vim.keymap.set('n', 'gh', function()
vim.o.operatorfunc = function(mtype)
table.insert(_G.oplog, mtype)
end
return 'g@'
end, { expr = true })
]])
feed('gg0ghgt')
eq({ 'char' }, n.exec_lua('return _G.oplog'))
ev = atom_last()
eq(
{ type = 'operator', operator = 'g@', lhs = 'ghgt', changed = false },
pick(ev, 'type', 'operator', 'lhs', 'cmd', 'changed')
)
t.matches('^g@\128', ev.keys) -- "g@" + K_LUA….
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq({ 'char', 'char' }, n.exec_lua('return _G.oplog'))
-- Lua 'operatorfunc' editing via ":norm!" must not clobber the captured "g@" redo/atom.
n.exec_lua([[
vim.keymap.set('n', 'gr', function()
vim.o.operatorfunc = function()
vim.cmd([=[normal! `[v`]rX]=])
end
return 'g@'
end, { expr = true })
]])
api.nvim_buf_set_lines(0, 0, -1, true, { 'mmm nnn', 'ooo ppp' })
feed('gg0grgt')
eq('XXX nnn', fn.getline(1))
ev = atom_last()
eq(
{ type = 'operator', operator = 'g@', lhs = 'grgt', changed = true },
pick(ev, 'type', 'operator', 'lhs', 'changed')
)
t.matches('^g@\128', ev.keys) -- "g@" + K_LUA….
feed('j0')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq('XXX ppp', fn.getline(2))
-- Aborted operator (cpo+=E empty region) cancels its redo: no atom captured.
command('set cpo+=E')
n.exec_lua([[vim.keymap.set('o', 'ge', function() end)]])
feed('dge')
eq('XXX ppp', fn.getline(2))
eq(
{ type = 'mapping', lhs = 'dge', changed = false },
pick(atom_last(), 'type', 'lhs', 'changed')
)
command('set cpo-=E')
end)
it('|restore-undo-cursor|: `pos` + `undoseq` restore across every undo form', function()