From bc16be3cd91b1d7508f91da97f5c95881eea5fc7 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sat, 29 Aug 2026 02:03:12 +0200 Subject: [PATCH] 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... --- src/nvim/input.c | 5 +- src/nvim/input_cmdatom.c | 36 ++++---- src/nvim/normal.c | 1 + src/nvim/ops.c | 6 ++ test/functional/editor/cmdatom_spec.lua | 113 ++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 17 deletions(-) diff --git a/src/nvim/input.c b/src/nvim/input.c index 96cf306ddf..150d6c6a34 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -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 }; diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index e83b4388a3..8c03d3b096 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -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") 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" 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/ '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"). Emit-only. Not if pattern was not found. + // Payload typed in search cmdline ("/pat"). 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); diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 46a182f6e4..00aecff3fc 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -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'); diff --git a/src/nvim/ops.c b/src/nvim/ops.c index 9d0b3632a4..0bfefad33b 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -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; diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index a701ef9ec5..ae361ff6ab 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -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('ggSabc0def') eq({ 'defabc', 'yyyy' }, get_lines()) + -- i_CTRL-O resolution is lossy: keys=nil, replay the "S"-labeled `lhs` instead. + eq( + { type = 'mapping', lhs = k('Sabc0def') }, + pick(atom_last(), 'type', 'lhs', 'keys') + ) -- r under 'expandtab'/'smarttab' ("{count}R": edit() does the tab). command('set expandtab shiftwidth=4 tabstop=4') api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh', 'ABCDEFGH' }) feed('gg03r') eq(' defgh', fn.getline(1)) + eq( + { type = 'insert', lhs = k('3r'), keys = k('3R') }, + 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()