mirror of
https://github.com/neovim/neovim.git
synced 2026-08-15 20:09:41 +00:00
Problem:
There is no unified notion of a "user action".
Vim processes input by one-char-at-a-time, and mostly throws away any
hints it might gather about the user's action, with one exception: it
stores the last _edit_ action (the "redo buffer", encoded as
unstructured `["x][v][count]body` bytes).
Plugins can only observe individual keys (vim.on_key) and high-level
effects (TextChanged, CursorMoved).
Solution:
- Users can subscribe to `CmdAtom` events to handle any user action.
- Event is deferred; handlers cannot cancel or interfere with user
actions.
- Capture `CmdSpec` from the normal/insert/visual subsystems.
- typeahead/readahead stay unstructured (`buffheader_T`): they are key
streams, not commands.
- the redo/record buffers become `StringBuilder`: fewer
allocations/copies.
- Repurpose the input/redo engine to accept `CmdSpec` objects.
"atom": one repeatable unit of user input, as a resolved (post-mapping)
keysequence plus structured fields. Only user actions, not `:normal`,
API calls, or non-"t" `feedkeys`.
BREAKING: dot-repeat of an Insert session, replays the entire session
including cursor-moves (:help ins-repeat).
BREAKING: dot-repeat of a Visual operation, replays the selection
instead of operating on a fixed-size region.
867 lines
31 KiB
Lua
867 lines
31 KiB
Lua
-- Tests for atom capture: the CmdAtom event, and dot-repeat of whole insert sessions.
|
|
|
|
local t = require('test.testutil')
|
|
local n = require('test.functional.testnvim')()
|
|
local Screen = require('test.functional.ui.screen')
|
|
local t_atom = require('test.functional.editor.atom_testutil')
|
|
|
|
local describe, it, before_each = t.describe, t.it, t.before_each
|
|
local retry = t.retry
|
|
local clear = n.clear
|
|
local command = n.command
|
|
local exec = n.exec
|
|
local feed = n.feed
|
|
local fn = n.fn
|
|
local eq = t.eq
|
|
local api = n.api
|
|
local get_lines = t_atom.get_lines
|
|
local k = t_atom.k
|
|
local atoms_start = t_atom.atoms_start
|
|
local atoms = t_atom.atoms
|
|
local atoms_tail = t_atom.atoms_tail
|
|
local atom_last = t_atom.atom_last
|
|
local pick = t_atom.pick
|
|
|
|
describe('dot-repeat', function()
|
|
before_each(clear)
|
|
|
|
it('replays insert-mode cursor-moves (the whole session)', function()
|
|
fn.setline(1, { 'one', 'two' })
|
|
feed('iab<Left>c<Esc>')
|
|
eq({ 'acbone', 'two' }, get_lines())
|
|
-- The ". register and undo still restart at the cursor-move, like Vim.
|
|
eq('c', fn.getreg('.'))
|
|
feed('u')
|
|
eq({ 'abone', 'two' }, get_lines())
|
|
feed('<C-r>')
|
|
-- "." re-executes the whole session, cursor-move included.
|
|
feed('j0.')
|
|
eq({ 'acbone', 'acbtwo' }, get_lines())
|
|
-- An absolute jump (<C-Home>) still restarts the capture: "." replays
|
|
-- only the post-jump insert.
|
|
feed('ggAxy<C-Home>z<Esc>')
|
|
eq({ 'zacbonexy', 'acbtwo' }, get_lines())
|
|
eq('z', fn.getreg('.'))
|
|
feed('j0.')
|
|
eq({ 'zacbonexy', 'zacbtwo' }, get_lines())
|
|
-- Vim's repeat-only-the-tail behavior is a mapping away (documented in
|
|
-- vim_diff.txt): i_CTRL-O re-entry restarts the capture.
|
|
command('inoremap <Left> <C-o><Left>')
|
|
feed('ggiab<Left>c<Esc>')
|
|
eq({ 'acbzacbonexy', 'zacbtwo' }, get_lines())
|
|
feed('j0.')
|
|
eq({ 'acbzacbonexy', 'czacbtwo' }, get_lines())
|
|
end)
|
|
end)
|
|
|
|
describe('CmdAtom', function()
|
|
before_each(clear)
|
|
|
|
it('a counted mapped motion carries its count in the atom', function()
|
|
command('nnoremap j gj')
|
|
fn.setline(1, { 'a1', 'b2', 'c3', 'd4', 'e5' })
|
|
feed('gg')
|
|
atoms_start()
|
|
feed('3j')
|
|
eq(4, fn.line('.'))
|
|
local ev = atom_last()
|
|
eq(
|
|
{ type = 'motion', lhs = 'j', keys = '3gj', count = 3 },
|
|
pick(ev, 'type', 'lhs', 'keys', 'count')
|
|
)
|
|
-- The "," repeat recipe: replaying the KEYS verbatim repeats the count.
|
|
feed('gg')
|
|
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
|
|
eq(4, fn.line('.'))
|
|
-- Multi-command mapping: the pre-typed count lands in the FIRST folded
|
|
-- command; the folded atom itself has no single count.
|
|
command('nnoremap <F6> xw')
|
|
fn.setline(1, { 'abcdef ghi', 'jkl' })
|
|
feed('gg0')
|
|
feed('3<F6>')
|
|
ev = atom_last()
|
|
eq({ type = 'mapping' }, pick(ev, 'type', 'count'))
|
|
eq({ keys = '3dl', count = 3 }, pick(ev.atoms[1], 'keys', 'count'))
|
|
end)
|
|
|
|
it('a Lua-callback mapping (the "]q" default) emits a mapping atom', function()
|
|
-- Same shape as the "]q" default mapping: a Lua callback with no
|
|
-- replayable keys. Still a user action: it publishes with an empty
|
|
-- replay payload.
|
|
n.exec_lua([[
|
|
vim.keymap.set('n', ']q', function()
|
|
vim.cmd({ cmd = 'cnext', count = vim.v.count1 })
|
|
end, { desc = ':cnext' })
|
|
]])
|
|
fn.setline(1, { 'aaa', 'bbb', 'ccc' })
|
|
local bufnr = api.nvim_get_current_buf()
|
|
fn.setqflist({ { bufnr = bufnr, lnum = 1 }, { bufnr = bufnr, lnum = 3 } })
|
|
command('cfirst')
|
|
atoms_start()
|
|
feed(']q')
|
|
eq(3, fn.line('.')) -- the mapping did run (:cnext)
|
|
eq(
|
|
{ type = 'mapping', lhs = ']q', keys = '', changed = false },
|
|
pick(atom_last(), 'type', 'lhs', 'keys', 'changed')
|
|
)
|
|
-- An empty-keys mapping that DOES edit still reports it: `changed` is
|
|
-- the only informative payload of a <Cmd>/Lua-callback edit.
|
|
n.exec_lua([[
|
|
vim.keymap.set('n', ',e', function()
|
|
vim.api.nvim_buf_set_lines(0, 0, 0, false, { 'NEW' })
|
|
end)
|
|
]])
|
|
feed(',e')
|
|
eq({ keys = '', changed = true }, pick(atom_last(), 'keys', 'changed'))
|
|
end)
|
|
|
|
it('motions, search, Ex emit without an edit', function()
|
|
-- Emission is not tied to editing: every user action publishes, so
|
|
-- plugins can observe all activity.
|
|
fn.setline(1, { 'alpha beta', 'gamma delta' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed('w')
|
|
feed('3l')
|
|
feed('/gamma<CR>')
|
|
feed(':nohlsearch<CR>')
|
|
eq({
|
|
{ type = 'motion', keys = 'w' },
|
|
{ type = 'motion', keys = '3l' },
|
|
{ type = 'motion', keys = k('/gamma<NL>') },
|
|
{ type = 'ex', keys = k(':nohlsearch<NL>') },
|
|
}, atoms_tail(4, 'type', 'keys'))
|
|
-- ":" embeds its count as the range prefill, never as composed digits;
|
|
-- the count field carries it.
|
|
feed('gg')
|
|
feed('2:<CR>')
|
|
eq(2, fn.line('.'))
|
|
eq(
|
|
{ type = 'ex', keys = k(':.,.+1<NL>'), count = 2, cmd = ':' },
|
|
pick(atom_last(), 'type', 'keys', 'count', 'cmd')
|
|
)
|
|
eq({ 'alpha beta', 'gamma delta' }, get_lines()) -- nothing was edited
|
|
-- Scrolls and mouse presses also emit (type "scroll"/"mouse"): emit-only,
|
|
-- never cascaded.
|
|
feed('<C-e>')
|
|
feed('3<C-y>')
|
|
api.nvim_input_mouse('wheel', 'up', '', 0, 0, 0)
|
|
api.nvim_input_mouse('left', 'press', '', 0, 1, 2)
|
|
eq({
|
|
{ type = 'scroll', keys = k('<C-E>'), cascade = false },
|
|
{ type = 'scroll', keys = k('3<C-Y>'), cascade = false },
|
|
{ type = 'scroll', keys = k('<ScrollWheelUp>'), cascade = false },
|
|
{ type = 'mouse', keys = k('<LeftMouse>'), cascade = false },
|
|
}, atoms_tail(4, 'type', 'keys', 'cascade'))
|
|
eq(2, fn.line('.')) -- the click moved the cursor
|
|
-- <amatch>/match is the atom type, never path-expanded.
|
|
n.exec_lua([[
|
|
vim.api.nvim_create_autocmd('CmdAtom', {
|
|
callback = function(ev)
|
|
_G.last_match = ev.match
|
|
end,
|
|
})
|
|
]])
|
|
feed('0w')
|
|
n.poke_eventloop()
|
|
eq('motion', n.exec_lua('return _G.last_match'))
|
|
end)
|
|
|
|
it('captures counts and payload chars', 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')
|
|
eq({ 'd,ef' }, get_lines())
|
|
feed('vf,d')
|
|
eq({ 'ef' }, get_lines())
|
|
eq({ '3dl', 'vf,d' }, atoms_tail(2))
|
|
eq(3, atoms()[#atoms() - 1].count)
|
|
-- Structured decomposition: operator/motion/operand as fields, no byte-parsing.
|
|
local op = atoms()[#atoms() - 1] -- "3dl"
|
|
eq(
|
|
{ operator = 'd', cmd = 'l', changed = true },
|
|
pick(op, 'operator', 'cmd', 'arg', '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"
|
|
eq({ type = 'visual', operator = 'd' }, pick(vis, 'type', 'operator'))
|
|
eq(
|
|
{
|
|
{ keys = 'v', cmd = 'v', changed = false },
|
|
{ keys = 'f,', cmd = 'f', arg = ',', changed = false },
|
|
{ keys = 'd', changed = true }, -- the completing operator did the edit
|
|
},
|
|
vim.tbl_map(function(c)
|
|
return pick(c, 'keys', 'cmd', 'arg', 'changed')
|
|
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' })
|
|
feed('gg0')
|
|
feed('d/END<CR>')
|
|
eq({ 'END bb' }, get_lines())
|
|
eq({ k('d/END<NL>') }, atoms_tail(1))
|
|
eq(
|
|
{ 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' })
|
|
feed('gg0magJ')
|
|
eq({ 'onetwo' }, get_lines()) -- "gJ": join without inserting a space
|
|
eq({ cmd = 'm', arg = 'a' }, pick(atoms()[#atoms() - 1], 'cmd', 'arg'))
|
|
eq({ cmd = 'gJ' }, pick(atom_last(), 'cmd', 'arg'))
|
|
local nrec = #atoms()
|
|
feed('qax') -- the recording register is an operand, not part of the name
|
|
feed('q')
|
|
eq({ cmd = 'q', arg = 'a' }, pick(atoms()[nrec + 1], 'cmd', 'arg'))
|
|
-- Forced motion type ("dvj") is a field.
|
|
fn.setline(1, { 'one', 'two' })
|
|
feed('gg0dvj')
|
|
eq(
|
|
{ operator = 'd', motionforce = 'v', cmd = 'j' },
|
|
pick(atom_last(), 'operator', 'motionforce', 'cmd')
|
|
)
|
|
end)
|
|
|
|
it('fires for typed input, never for programmatic sources', function()
|
|
atoms_start()
|
|
-- Drain deferred CmdAtom events, then return + clear the collected list.
|
|
local function take()
|
|
n.poke_eventloop()
|
|
return n.exec_lua([[local a = _G.atoms; _G.atoms = {}; return a]])
|
|
end
|
|
-- Fresh buffer + cursor via the API (no keys, so no motion atoms).
|
|
local function fresh()
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'aaaaaaaa', 'bbbbbbbb' })
|
|
api.nvim_win_set_cursor(0, { 1, 0 })
|
|
take()
|
|
end
|
|
|
|
-- Typed input emits: real keys (nvim_input, via feed())...
|
|
fresh()
|
|
feed('x')
|
|
eq(1, #take())
|
|
-- ...and nvim_feedkeys() with the "t" (typed) flag.
|
|
n.exec_lua([[vim.api.nvim_feedkeys('x', 't', false)]])
|
|
eq(1, #take())
|
|
|
|
-- A typed macro folds into exactly ONE atom, labeled "@q".
|
|
fresh()
|
|
feed('qqxq') -- recording is typed input
|
|
take()
|
|
feed('@q')
|
|
local evs = take()
|
|
eq(1, #evs)
|
|
eq('@q', evs[1].lhs)
|
|
-- Same for "Q" (replay the last recorded register).
|
|
feed('Q')
|
|
evs = take()
|
|
eq(1, #evs)
|
|
eq({ lhs = '@q', keys = 'dl' }, pick(evs[1], 'lhs', 'keys'))
|
|
|
|
-- Programmatic / replayed input must NOT leak any atom.
|
|
fresh()
|
|
api.nvim_buf_set_lines(0, 0, 1, true, { 'ZZZZ' }) -- API buffer edit
|
|
eq(0, #take())
|
|
api.nvim_buf_set_text(0, 0, 0, 0, 1, { 'Q' })
|
|
eq(0, #take())
|
|
command('normal! x') -- :normal!
|
|
eq(0, #take())
|
|
command('normal x') -- :normal (with mappings)
|
|
eq(0, #take())
|
|
n.exec_lua([[vim.cmd('normal! x')]])
|
|
eq(0, #take())
|
|
n.exec_lua([[vim.api.nvim_feedkeys('x', '', false)]]) -- feedkeys without "t"
|
|
eq(0, #take())
|
|
command('normal! @q') -- macro played programmatically, not typed
|
|
eq(0, #take())
|
|
n.exec_lua([[vim.api.nvim_feedkeys('@q', '', false)]])
|
|
eq(0, #take())
|
|
command('normal! yy') -- prep-exempt operator (a yank builds no redo)
|
|
eq(0, #take())
|
|
n.exec_lua([[vim.api.nvim_feedkeys('viwd', '', false)]]) -- Visual sequence
|
|
eq(0, #take())
|
|
|
|
-- A timer/scheduled API edit must NOT leak.
|
|
n.exec_lua([[
|
|
_G.done = false
|
|
vim.defer_fn(function()
|
|
vim.api.nvim_buf_set_lines(0, 0, 1, true, { 'TTTT' })
|
|
_G.done = true
|
|
end, 5)
|
|
]])
|
|
n.exec_lua('vim.wait(200, function() return _G.done end)')
|
|
eq(0, #take())
|
|
|
|
-- Sanity: typed input still emits after all the programmatic noise.
|
|
fresh()
|
|
feed('x')
|
|
eq(1, #take())
|
|
end)
|
|
|
|
it('visual atom keys re-execute on replay; unreplayable ops fall back to equal-size', function()
|
|
-- The core use-case for a plugin: observe CmdAtom, capture a Visual-mode
|
|
-- operation's resolved `keys`, and replay them verbatim to re-execute it.
|
|
fn.setline(1, { 'foo bar', 'longword bar' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed('viwd') -- select the inner word and delete it
|
|
eq({ ' bar', 'longword bar' }, get_lines())
|
|
local ev = atom_last()
|
|
eq('visual', ev.type)
|
|
-- Replay the captured keys at line 2. The keysequence RE-EXECUTES (not an
|
|
-- equal-size reselect): "iw" selects THAT line's word (the longer one),
|
|
-- so the delete adapts to the new context.
|
|
feed('j0')
|
|
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
|
|
eq({ ' bar', ' bar' }, get_lines())
|
|
-- Builtin |.| replays the same keysequence: own-sized re-execution, not
|
|
-- Vim's equal-size reselect (|visual-repeat|).
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' })
|
|
feed('gg0viwd')
|
|
eq({ ' bar', 'longword bar' }, get_lines())
|
|
feed('j0.')
|
|
eq({ ' bar', ' bar' }, get_lines())
|
|
-- A viewport scroll that drags the cursor along (edge/'scrolloff') grows
|
|
-- the selection by a viewport-dependent amount: void, no atom.
|
|
local lines = {}
|
|
for i = 1, 30 do
|
|
lines[i] = 'l' .. i
|
|
end
|
|
fn.setline(1, lines)
|
|
feed('gg')
|
|
local before = #atoms()
|
|
feed('V<C-e>')
|
|
n.poke_eventloop()
|
|
eq(2, fn.line('.')) -- the scroll dragged the cursor: selection is lines 1-2
|
|
feed('d')
|
|
eq(before, #atoms()) -- not replayable: no atom published for the edit
|
|
eq('l3', fn.getline(1)) -- the edit itself deleted both selected lines
|
|
-- "." on the unreplayable operation falls back to an equal-size reselect
|
|
-- ("1v" + operator): it deletes the same number of lines at the cursor.
|
|
feed('.')
|
|
eq('l5', fn.getline(1))
|
|
-- A fed (":normal!") Visual put preps the selection keysequence, like any fed visual
|
|
-- operator (":normal! vjd"): "." re-executes "Vjp", not a bare "p".
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'aa', 'bb', 'cc', 'dd', 'ee' })
|
|
feed('ggyy')
|
|
command('normal! Vjp')
|
|
eq({ 'aa', 'cc', 'dd', 'ee' }, get_lines())
|
|
feed('j.')
|
|
eq({ 'aa', 'aa', 'bb', 'ee' }, get_lines())
|
|
-- {Visual}r<C-V><CR> replaces with a literal <CR> (REPLACE_CR_NCHAR): inexpressible as
|
|
-- spec chars, so the literal keys compose the redo tail, which "." replays.
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcd', 'efgh' })
|
|
feed('gg0vlr<C-V><CR>')
|
|
eq({ '\r\rcd', 'efgh' }, get_lines())
|
|
feed('j0.')
|
|
eq({ '\r\rcd', '\r\rgh' }, get_lines())
|
|
end)
|
|
|
|
it('a mapping can repeat the last visual atom', function()
|
|
-- User-defined Visual dot-repeat: capture a visual atom's resolved
|
|
-- `keys`, replay them verbatim from a mapping to RE-EXECUTE the
|
|
-- operation (not an equal-size reselect like builtin |.|).
|
|
n.exec_lua([[
|
|
vim.api.nvim_create_autocmd('CmdAtom', {
|
|
pattern = 'visual',
|
|
callback = function(ev)
|
|
_G.last_visual = ev.data.keys
|
|
end,
|
|
})
|
|
vim.keymap.set('n', ',', function()
|
|
-- Scheduled: runs after any pending CmdAtom event (fresh
|
|
-- `last_visual`), and emits no CmdAtom itself. See |CmdAtom|.
|
|
vim.schedule(function()
|
|
if _G.last_visual then
|
|
vim.api.nvim_feedkeys(_G.last_visual, 'n', false)
|
|
end
|
|
end)
|
|
end)
|
|
]])
|
|
--- Count of captured visual atoms.
|
|
local function nvisual()
|
|
local count = 0
|
|
for _, a in ipairs(atoms()) do
|
|
if a.type == 'visual' then
|
|
count = count + 1
|
|
end
|
|
end
|
|
return count
|
|
end
|
|
fn.setline(1, { 'foo bar', 'longword bar' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed('viwd')
|
|
eq({ ' bar', 'longword bar' }, get_lines())
|
|
eq('visual', atom_last().type)
|
|
-- "iw" re-executes: it selects THAT line's (longer) word.
|
|
feed('j0,')
|
|
retry(nil, 1000, function()
|
|
eq({ ' bar', ' bar' }, get_lines())
|
|
end)
|
|
-- The scheduled replay is programmatic input: it emits no visual atom itself.
|
|
eq(1, nvisual())
|
|
-- A Visual change (insert session): the atom embeds the selection, the
|
|
-- operator, the inserted text, and <Esc>; the repeat re-executes it all.
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' })
|
|
feed('gg0viwcX<Esc>')
|
|
eq({ 'X bar', 'longword bar' }, get_lines())
|
|
feed('j0,')
|
|
retry(nil, 1000, function()
|
|
eq({ 'X bar', 'X bar' }, get_lines())
|
|
end)
|
|
eq(2, nvisual())
|
|
end)
|
|
|
|
it('a mapping can restore equal-size visual dot-repeat', function()
|
|
-- Keep in sync with the example in runtime/doc/repeat.txt.
|
|
n.exec_lua([[
|
|
local vop ---@type string?
|
|
vim.api.nvim_create_autocmd('CmdAtom', {
|
|
callback = function(ev)
|
|
if ev.data.type == 'visual' then
|
|
local children = ev.data.atoms
|
|
vop = children and children[#children].keys or nil
|
|
elseif ev.data.changed then
|
|
vop = nil -- the last change is no longer the Visual one
|
|
end
|
|
end,
|
|
})
|
|
vim.keymap.set('n', '.', function()
|
|
vim.schedule(function()
|
|
vim.api.nvim_feedkeys(vop and ('1v' .. vop) or '.', 'n', false)
|
|
end)
|
|
end)
|
|
]])
|
|
fn.setline(1, { 'foo bar', 'longword bar' })
|
|
feed('gg0viwd')
|
|
eq({ ' bar', 'longword bar' }, get_lines())
|
|
-- Equal-size repeat: a 3-char region at the cursor, NOT that line's word.
|
|
feed('j0.')
|
|
retry(nil, 1000, function()
|
|
eq({ ' bar', 'gword bar' }, get_lines())
|
|
end)
|
|
-- A non-visual change falls back to the builtin |.|.
|
|
feed('gg0x')
|
|
eq({ 'bar', 'gword bar' }, get_lines())
|
|
feed('j0.')
|
|
retry(nil, 1000, function()
|
|
eq({ 'bar', 'word bar' }, get_lines())
|
|
end)
|
|
end)
|
|
|
|
it('one event per operation, for each kind of atom', function()
|
|
n.clear({ args = { '--clean' }, args_rm = { '--cmd' } })
|
|
--- Feeds `keys`, asserts exactly ONE new event, with the given keys.
|
|
local function atom(keys, expected)
|
|
local before = #atoms()
|
|
feed(keys)
|
|
local evs = atoms()
|
|
eq({ before + 1, k(expected) }, { #evs, evs[#evs].keys })
|
|
end
|
|
local lines = {}
|
|
for i = 1, 20 do
|
|
lines[i] = 'alpha beta gamma delta epsilon zeta'
|
|
end
|
|
fn.setline(1, lines)
|
|
feed('gg0')
|
|
atoms_start()
|
|
-- Operators: the atom is the redobuff (count/register included).
|
|
-- "x" is normalized ("translated") to the elemental command "dl".
|
|
atom('x', 'dl')
|
|
atom('3x', '3dl')
|
|
atom('dw', 'dw')
|
|
atom('"z2dw', '"z2dw')
|
|
atom('yy', 'yy')
|
|
atom('p', 'p')
|
|
atom('J', '2J')
|
|
atom('3J', '3J')
|
|
atom('r?', '1r?')
|
|
atom('~', '~')
|
|
atom('guiw', 'guiw')
|
|
atom('gUiw', 'gUiw')
|
|
atom('>>', '>>')
|
|
atom('dfa', 'dfa')
|
|
feed('ddk')
|
|
atom('P', 'P')
|
|
-- Insert sessions: one whole-session atom (entry + text + <Esc>).
|
|
atom('iXY<Esc>', '1iXY<Esc>')
|
|
atom('A!<Esc>', '1A!<Esc>')
|
|
atom('oNEW<Esc>', '1oNEW<Esc>')
|
|
atom('3iZ<Esc>', '3iZ<Esc>')
|
|
atom('cwWORD<Esc>', 'cwWORD<Esc>')
|
|
-- Visual: the full typed keysequence.
|
|
atom('viwd', 'viwd')
|
|
atom('Vd', 'Vd')
|
|
atom('<C-v>jd', '<C-V>jd')
|
|
-- Motions.
|
|
atom('w', 'w')
|
|
atom('3w', '3w')
|
|
atom('fb', 'fb')
|
|
atom('G', 'G')
|
|
atom('$', '$')
|
|
feed('gg0')
|
|
atom(']]', ']]')
|
|
-- Jumps: absolute/shared-state navigation, their own kind.
|
|
atom('ma', 'ma')
|
|
eq('command', atom_last().type) -- "m" sets state; it does not jump
|
|
atom('`a', '`a')
|
|
eq('jump', atom_last().type)
|
|
atom('<C-o>', '<C-O>')
|
|
eq('jump', atom_last().type)
|
|
-- Non-redoable commands: still emitted, as type "command".
|
|
atom('zz', 'zz')
|
|
eq('command', atom_last().type)
|
|
atom('u', 'u')
|
|
atom('<C-r>', '<C-R>')
|
|
-- "." emits its resolution (like "x" => "dl").
|
|
feed('gg0')
|
|
atom('x', 'dl')
|
|
atom('.', 'dl')
|
|
atom('3.', '3dl') -- "3.": the new count replaces the captured one
|
|
-- Payload commands: the interactively-typed cmdline completes the
|
|
-- keysequence (not a bare "/" or ":" prefix).
|
|
atom('/beta<CR>', '/beta<NL>')
|
|
eq({ type = 'motion', text = 'beta' }, pick(atom_last(), 'type', 'text'))
|
|
atom('?alpha<CR>', '?alpha<NL>')
|
|
atom('2/beta<CR>', '2/beta<NL>')
|
|
-- Ex commands: their own atom kind, the cmdline is the "text" payload.
|
|
atom(':set tw=42<CR>', ':set<Space>tw=42<NL>')
|
|
eq({ type = 'ex', text = 'set tw=42' }, pick(atom_last(), 'type', 'text'))
|
|
-- A nested cmdline opened by the command's own execution (":normal")
|
|
-- does not hijack the payload.
|
|
atom(':exe "normal! :echo 1\\r"<CR>', ':exe<Space>"normal!<Space>:echo<Space>1<Bslash>r"<NL>')
|
|
-- A mapping is ALWAYS an atom, even when its commands capture no
|
|
-- replayable keys ("]q" = :cnext): the event has empty keys.
|
|
fn.setqflist({ { text = 'one' }, { text = 'two' } })
|
|
feed(']q')
|
|
eq(
|
|
{ type = 'mapping', lhs = ']q', keys = '' },
|
|
pick(atom_last(), 'type', 'lhs', 'keys', 'pending')
|
|
)
|
|
-- A mapping that ends mid-operation says what it awaits.
|
|
command('nnoremap ,D d')
|
|
command('nnoremap ,V v')
|
|
feed(',D')
|
|
eq({ lhs = ',D', pending = 'operator' }, pick(atom_last(), 'lhs', 'pending'))
|
|
atom('w', 'dw') -- the supplied motion completes the operation
|
|
feed(',V')
|
|
eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending'))
|
|
feed('<Esc>')
|
|
-- An ABORTED mapping emits nothing: an error discards its remaining
|
|
-- keys, and the composite with them.
|
|
command('nnoremap ,E :NoSuchCmd<CR>x')
|
|
local count = #atoms()
|
|
feed(',E') -- E492 mid-mapping: the trailing "x" never runs
|
|
eq(count, #atoms())
|
|
atom('x', 'dl') -- the next command is not folded into the dead composite
|
|
eq(nil, atom_last().lhs) -- not from a mapping: omitted
|
|
command('nmap ,A ,B')
|
|
command('nmap ,B ,A')
|
|
feed(',A') -- E223: recursive mapping
|
|
atom('x', 'dl')
|
|
eq(nil, atom_last().lhs) -- not from a mapping: omitted
|
|
-- Macro playback ("@q") emits its commands' atoms (capture-on-replay);
|
|
-- "@q" itself is a translation, never an atom.
|
|
local total = #atoms()
|
|
feed('qqxq')
|
|
feed('@q')
|
|
eq(total + 4, #atoms())
|
|
eq({ 'qq', 'dl', 'q', 'dl' }, atoms_tail(4))
|
|
-- The macro's atoms fold into one "@x"-labeled composite, like a mapping.
|
|
eq('@q', atom_last().lhs)
|
|
-- A typed scroll emits its own (emit-only) atom kind.
|
|
feed('<C-d>')
|
|
eq(total + 5, #atoms())
|
|
eq({ type = 'scroll', keys = k('<C-D>') }, pick(atom_last(), 'type', 'keys'))
|
|
-- An 'indentexpr' that runs ":normal" opens a nested command-frame mid-operator: "gq" still
|
|
-- pushes its own atom, after the edit.
|
|
exec([[
|
|
func Indent()
|
|
exe "normal! \<Ignore>"
|
|
return 0
|
|
endfunc
|
|
setlocal indentexpr=Indent() textwidth=20
|
|
]])
|
|
atom('Vgq', 'Vgq')
|
|
eq(true, atom_last().changed)
|
|
end)
|
|
|
|
it('captures non-edit operators (zfap) and fold/view commands', function()
|
|
fn.setline(1, { 'aa', 'aa', '' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed('zfap')
|
|
eq({ 'zfap' }, atoms_tail(1))
|
|
eq('operator', atom_last().type)
|
|
eq(1, fn.foldclosed(1))
|
|
feed('za')
|
|
eq({ 'za' }, atoms_tail(1))
|
|
-- Neither an operator nor a motion: its own kind.
|
|
eq('command', atom_last().type)
|
|
eq(-1, fn.foldclosed(1))
|
|
end)
|
|
|
|
it('a single-command mapping keeps its own type and structure', function()
|
|
command('nnoremap ,d dw')
|
|
fn.setline(1, { 'one two aa bb cc dd' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed(',d')
|
|
eq({ 'two aa bb cc dd' }, get_lines())
|
|
-- The event's structured fields mirror what is encoded in "keys"; a
|
|
-- mapping labels its atom with the typed LHS (a single-command
|
|
-- mapping: one event, keeping the command's own type and structure).
|
|
local evs = atoms()
|
|
eq(1, #evs)
|
|
-- Inapplicable fields (count/reg/arg/motionforce/text/pending/atoms here) are omitted.
|
|
eq({
|
|
type = 'operator',
|
|
keys = 'dw',
|
|
operator = 'd',
|
|
cmd = 'w',
|
|
changed = true,
|
|
cascade = true, -- the edit is cascadable
|
|
lhs = ',d',
|
|
}, evs[#evs])
|
|
-- Count and register are captured; one event per occurrence.
|
|
feed('"z2dw')
|
|
feed('"z2dw')
|
|
evs = atoms()
|
|
eq({ keys = '"z2dw', count = 2, reg = 'z' }, pick(evs[#evs], 'keys', 'count', 'reg'))
|
|
eq(evs[#evs - 1], evs[#evs])
|
|
end)
|
|
|
|
it('a mapping with edits and motions folds into one atom', function()
|
|
-- Split the line at the cursor, ending at the EOL of the first half.
|
|
command('nnoremap gj i<c-j><esc>k$')
|
|
fn.setline(1, { 'aaa bbb' })
|
|
feed('gg04l')
|
|
atoms_start()
|
|
feed('gj')
|
|
eq({ 'aaa ', 'bbb' }, get_lines())
|
|
-- The mapping IS the atom: its commands (the insert session, k, $)
|
|
-- accumulate and fold into exactly ONE event, labeled with the typed
|
|
-- LHS; the resolved keys remain the (replayable) payload. The
|
|
-- mapping is never re-resolved.
|
|
local evs = atoms()
|
|
eq(1, #evs)
|
|
eq(
|
|
{ type = 'mapping', lhs = 'gj', keys = k('1i<NL><Esc>k$'), changed = true },
|
|
pick(evs[1], 'type', 'lhs', 'keys', 'changed')
|
|
)
|
|
-- The folded commands stay exposed, each with its own structure.
|
|
eq(
|
|
{
|
|
{ type = 'insert', keys = k('1i<NL><Esc>') },
|
|
{ type = 'motion', keys = 'k' },
|
|
{ type = 'motion', keys = '$' },
|
|
},
|
|
vim.tbl_map(function(c)
|
|
return pick(c, 'type', 'keys')
|
|
end, evs[1].atoms)
|
|
)
|
|
eq({ 'k', false }, { evs[1].atoms[2].cmd, evs[1].atoms[2].changed })
|
|
-- A scroll inside a mapping is NOT a subatom: the composite's keys must
|
|
-- stay replayable, so the scroll is elided.
|
|
fn.setline(1, { 'l1', 'l2', 'l3', 'l4', 'l5', 'l6' })
|
|
feed('3G')
|
|
command('nnoremap gk <C-e>j$')
|
|
feed('gk')
|
|
eq(4, fn.line('.'))
|
|
eq({ type = 'mapping', lhs = 'gk', keys = 'j$' }, pick(atom_last(), 'type', 'lhs', 'keys'))
|
|
-- A recursive mapping (:nmap gJ gj) does not nest: the inner mapping's commands flatten
|
|
-- into ONE composite labeled with the typed LHS, with the same resolved keys.
|
|
command('nmap gJ gj')
|
|
api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa bbb' })
|
|
feed('gg04l')
|
|
local before = #atoms()
|
|
feed('gJ')
|
|
eq({ 'aaa ', 'bbb' }, get_lines())
|
|
evs = atoms()
|
|
eq(before + 1, #evs)
|
|
eq(
|
|
{ type = 'mapping', lhs = 'gJ', keys = k('1i<NL><Esc>k$') },
|
|
pick(evs[#evs], 'type', 'lhs', 'keys')
|
|
)
|
|
end)
|
|
|
|
it('an insert session atom captures its text', function()
|
|
fn.setline(1, { 'aaa' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
-- No event for the bare "i" command, and none per keystroke: ONE
|
|
-- whole-session event at <Esc>.
|
|
feed('i')
|
|
eq(0, #atoms())
|
|
feed('X')
|
|
eq(0, #atoms())
|
|
feed('Y')
|
|
eq(0, #atoms())
|
|
feed('<Esc>')
|
|
local evs = atoms()
|
|
eq(1, #evs)
|
|
-- `count` mirrors what the keys encode: insert keys always embed the count
|
|
-- ("1i…"), so it is 1 even untyped. "dw" omits its count.
|
|
eq(
|
|
{ type = 'insert', count = 1, text = 'XY', keys = k('1iXY<Esc>') },
|
|
pick(evs[1], 'type', 'count', 'text', 'keys')
|
|
)
|
|
-- Counted insert: entry cmd + text + <Esc> keys, with count and the
|
|
-- session's inserted text as fields.
|
|
feed('3iZ<Esc>')
|
|
eq({ type = 'insert', count = 3, text = 'Z' }, pick(atom_last(), 'type', 'count', 'text'))
|
|
end)
|
|
|
|
it("operatorfunc atom includes the getchar()'d payload", function()
|
|
n.exec(t_atom.minisurround_vim)
|
|
fn.setline(1, { 'alpha beta' })
|
|
feed('gg0')
|
|
atoms_start()
|
|
feed('ysiw"')
|
|
-- The atom is the redobuff plus the getchar()'d payload: a replayed
|
|
-- opfunc reads the same wrap char.
|
|
eq({ 'g@iw"' }, atoms_tail(1))
|
|
eq({ '"alpha" beta' }, get_lines())
|
|
end)
|
|
|
|
it('a ":call" payload mapping publishes its resolved RHS', function()
|
|
-- The atom published to CmdAtom carries the RESOLVED RHS (the ":call" line).
|
|
n.exec(t_atom.delsurround_vim)
|
|
fn.setline(1, { 'a (one)' })
|
|
feed('gg0f(')
|
|
atoms_start()
|
|
feed('ds)') -- ")" is the getchar()'d payload
|
|
eq({ 'a one' }, get_lines())
|
|
local ev = atoms()[#atoms()]
|
|
eq('ds', ev.lhs)
|
|
t.matches(':call DelSurround%(%)', ev.keys)
|
|
end)
|
|
|
|
it('"," repeats the last motion atom', function()
|
|
-- Keep in sync with the example in runtime/doc/repeat.txt.
|
|
n.exec_lua([[
|
|
local last ---@type string?
|
|
vim.api.nvim_create_autocmd('CmdAtom', {
|
|
pattern = 'motion',
|
|
callback = function(ev)
|
|
last = ev.data.keys
|
|
end,
|
|
})
|
|
vim.keymap.set('n', ',', function()
|
|
-- CmdAtom delivery is deferred: schedule the replay AFTER any pending
|
|
-- event, so `last` is fresh even when "," immediately follows a
|
|
-- motion. A scheduled replay is programmatic input: it emits no
|
|
-- CmdAtom itself (no feedback loop).
|
|
vim.schedule(function()
|
|
if last then
|
|
vim.api.nvim_feedkeys(last, 'n', false) -- "n": already resolved
|
|
end
|
|
end)
|
|
end)
|
|
]])
|
|
local screen = Screen.new(30, 3)
|
|
fn.setline(1, { 'aa bb cc dd ee' })
|
|
feed('gg0')
|
|
feed('2w') -- last motion: "2w" (count included)
|
|
n.poke_eventloop() -- deliver the deferred CmdAtom before ","
|
|
screen:expect([[
|
|
aa bb ^cc dd ee |
|
|
{1:~ }|
|
|
|
|
|
]])
|
|
feed(',') -- repeated: "2w" again
|
|
screen:expect([[
|
|
aa bb cc dd ^ee |
|
|
{1:~ }|
|
|
|
|
|
]])
|
|
feed('0fb') -- last motion: "fb" (payload char included)
|
|
n.poke_eventloop()
|
|
screen:expect([[
|
|
aa ^bb cc dd ee |
|
|
{1:~ }|
|
|
|
|
|
]])
|
|
feed(',') -- repeated "fb": the second "b"
|
|
screen:expect([[
|
|
aa b^b cc dd ee |
|
|
{1:~ }|
|
|
|
|
|
]])
|
|
end)
|
|
|
|
it('activates a temporary mapping ("submode"), expired by the next unrelated atom', function()
|
|
-- Keep in sync with the example in runtime/doc/repeat.txt.
|
|
n.exec_lua([==[
|
|
local active = false
|
|
vim.api.nvim_create_autocmd('CmdAtom', {
|
|
callback = function(ev)
|
|
-- `cmd` is a key-notation name (unlike `keys`, which is raw bytes).
|
|
local resize = ev.data.cmd == '<C-W>+' or ev.data.cmd == '<C-W>-'
|
|
if resize then
|
|
-- Activate. Use of "+"/"-" resolves to the same cmd => re-activates.
|
|
vim.keymap.set('n', '+', '<C-w>+')
|
|
vim.keymap.set('n', '-', '<C-w>-')
|
|
active = true
|
|
elseif active then
|
|
vim.keymap.del('n', '+')
|
|
vim.keymap.del('n', '-')
|
|
active = false
|
|
end
|
|
end,
|
|
})
|
|
-- Also works if <c-w>+ was mapped to something else:
|
|
vim.cmd[[nnoremap <leader>+ <c-w>+]]
|
|
]==])
|
|
--- Waits for the deferred CmdAtom to (de)activate the temporary mappings.
|
|
local function wait_active(active)
|
|
eq(
|
|
true,
|
|
n.exec_lua(
|
|
[[
|
|
local active = ...
|
|
return vim.wait(1000, function()
|
|
return (vim.fn.maparg('+', 'n') ~= '') == active
|
|
end)
|
|
]],
|
|
active
|
|
)
|
|
)
|
|
end
|
|
fn.setline(1, { 'one', 'two' })
|
|
command('split')
|
|
local height = fn.winheight(0)
|
|
feed('<C-w>+')
|
|
wait_active(true) -- the deferred CmdAtom activated the mappings
|
|
eq(height + 1, fn.winheight(0))
|
|
feed('+') -- temporary mapping: resizes without the CTRL-W prefix
|
|
n.poke_eventloop()
|
|
eq(height + 2, fn.winheight(0))
|
|
feed('-') -- its own use emits the same resolved keys: stays active
|
|
n.poke_eventloop()
|
|
eq(height + 1, fn.winheight(0))
|
|
feed('j') -- any unrelated atom expires the mappings
|
|
wait_active(false)
|
|
feed('-') -- back to the builtin: a motion (up one line), not a resize
|
|
n.poke_eventloop()
|
|
eq(height + 1, fn.winheight(0))
|
|
eq(1, fn.line('.'))
|
|
-- A mapping whose atom RESOLVES to a resize also activates the submode.
|
|
feed('\\+')
|
|
n.poke_eventloop()
|
|
eq(height + 2, fn.winheight(0))
|
|
wait_active(true)
|
|
end)
|
|
end)
|