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

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