-- 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('iabc') 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('') -- "." re-executes the whole session, cursor-move included. feed('j0.') eq({ 'acbone', 'acbtwo' }, get_lines()) -- An absolute jump () still restarts the capture: "." replays -- only the post-jump insert. feed('ggAxyz') 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 ') feed('ggiabc') 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 xw') fn.setline(1, { 'abcdef ghi', 'jkl' }) feed('gg0') feed('3') 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 /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') feed(':nohlsearch') eq({ { type = 'motion', keys = 'w' }, { type = 'motion', keys = '3l' }, { type = 'motion', keys = k('/gamma') }, { type = 'ex', keys = k(':nohlsearch') }, }, 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:') eq(2, fn.line('.')) eq( { type = 'ex', keys = k(':.,.+1'), 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('') feed('3') api.nvim_input_mouse('wheel', 'up', '', 0, 0, 0) api.nvim_input_mouse('left', 'press', '', 0, 1, 2) eq({ { type = 'scroll', keys = k(''), cascade = false }, { type = 'scroll', keys = k('3'), cascade = false }, { type = 'scroll', keys = k(''), cascade = false }, { type = 'mouse', keys = k(''), cascade = false }, }, atoms_tail(4, 'type', 'keys', 'cascade')) eq(2, fn.line('.')) -- the click moved the cursor -- /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') eq({ 'END bb' }, get_lines()) eq({ k('d/END') }, 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') 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 replaces with a literal (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') 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 ; the repeat re-executes it all. api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' }) feed('gg0viwcX') 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 + ). atom('iXY', '1iXY') atom('A!', '1A!') atom('oNEW', '1oNEW') atom('3iZ', '3iZ') atom('cwWORD', 'cwWORD') -- Visual: the full typed keysequence. atom('viwd', 'viwd') atom('Vd', 'Vd') atom('jd', '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('', '') 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('', '') -- "." 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', '/beta') eq({ type = 'motion', text = 'beta' }, pick(atom_last(), 'type', 'text')) atom('?alpha', '?alpha') atom('2/beta', '2/beta') -- Ex commands: their own atom kind, the cmdline is the "text" payload. atom(':set tw=42', ':settw=42') 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"', ':exe"normal!:echo1r"') -- 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('') -- An ABORTED mapping emits nothing: an error discards its remaining -- keys, and the composite with them. command('nnoremap ,E :NoSuchCmdx') 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('') eq(total + 5, #atoms()) eq({ type = 'scroll', keys = k('') }, 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! \" 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 ik$') 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('1ik$'), 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') }, { 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 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('1ik$') }, 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 . feed('i') eq(0, #atoms()) feed('X') eq(0, #atoms()) feed('Y') eq(0, #atoms()) feed('') 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') }, pick(evs[1], 'type', 'count', 'text', 'keys') ) -- Counted insert: entry cmd + text + keys, with count and the -- session's inserted text as fields. feed('3iZ') 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 == '+' or ev.data.cmd == '-' if resize then -- Activate. Use of "+"/"-" resolves to the same cmd => re-activates. vim.keymap.set('n', '+', '+') vim.keymap.set('n', '-', '-') active = true elseif active then vim.keymap.del('n', '+') vim.keymap.del('n', '-') active = false end end, }) -- Also works if + was mapped to something else: vim.cmd[[nnoremap + +]] ]==]) --- 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('+') 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)