-- Multicursor tests. 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 pending = t.pending local clear = n.clear local command = n.command 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 --- Clears the buffer mcursors like the default CTRL-L mapping (test-harness "mapclear" removed it). local function clear_cursors() n.exec_lua( [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, -1)]] ) end --- Number of multicursors. local function ncursors() local ns = api.nvim_create_namespace('nvim.multicursor') return #api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) end --- Positions ({row, col}, 0-based, in position order) of the mcursors. local function anchors() local ns = api.nvim_create_namespace('nvim.multicursor') local positions = {} for _, m in ipairs(api.nvim_buf_get_extmarks(0, ns, 0, -1, {})) do positions[#positions + 1] = { m[2], m[3] } end return positions end --- Sets the buffer lines, then places cursors by "Q". local function cursors(lines, place) api.nvim_buf_set_lines(0, 0, -1, true, lines) feed('gg0') feed(place or 'QjQj') end --- Asserts table-driven cascade rows. --- - `row.place` (default: "gg0") places the primary. --- - `row.keys` input (the "Q" placements and the operation), then compare the whole buffer. --- - `row.after` optionally feeds a key after the operation. --- - `row.pre` runs a :command first (such rows go last: the option persists). local function assert_rows(rows) for _, row in ipairs(rows) do clear_cursors() if row.pre then command(row.pre) end api.nvim_buf_set_lines(0, 0, -1, true, row.lines) feed(row.place or 'gg0') feed(row.keys) eq(row.expect, get_lines(), row.keys) if row.after then feed(row.after) eq(row.after_expect, get_lines(), ('%s ; %s'):format(row.keys, row.after)) end end end describe('multicursor', function() before_each(function() clear() command('hi MCursor guifg=Black guibg=LightGrey') end) describe('Q (add cursor)', function() it('does not modify buffer', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) end) it('creates a cursor again after clearing all', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') clear_cursors() clear_cursors() -- Repeated clear (nothing to remove) is a no-op. eq(0, ncursors()) eq(false, n.exec_lua("return require('vim._core.mcursor').active()")) feed('Q') eq(1, ncursors()) eq(true, n.exec_lua("return require('vim._core.mcursor').active()")) feed('gg0x') eq({ 'aa', 'bb', 'ccc' }, get_lines()) end) it('clearing mcursors also disables q= follow-mode', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') feed('q=') clear_cursors() feed('Q') eq(1, ncursors()) feed('l') eq(1, ncursors()) -- Partial-range clear does not end the mc-session; the remaining cursors keep "q=". clear_cursors() cursors({ 'aaa', 'bbb', 'ccc' }) -- cursors on lines 1-2, primary on line 3 feed('q=') n.exec_lua( [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, 1)]] ) eq(1, ncursors()) -- cursor 1 is gone, cursor 2 still exists. feed('l') eq({ { 1, 1 } }, anchors()) -- Still in follow-mode. end) it('nvim_mcursor() at an existing cursor is a no-op (no double-apply)', function() fn.setline(1, { 'abcdef', 'ghijkl' }) feed('gg0') api.nvim_mcursor(0, { 1, 0 }) api.nvim_mcursor(0, { 1, 0 }) -- The duplicate is ignored ("Q" toggles instead)... eq(1, ncursors()) feed('j0x') -- ...so the edit applies once at the line-1 cursor. eq({ 'bcdef', 'hijkl' }, get_lines()) eq(1, ncursors()) end) it('adds a cursor in the current buffer while mcursors exist in another', function() cursors({ 'aaa', 'bbb' }, 'Q') eq(1, ncursors()) command('set hidden') feed(':enew') -- Typed, so the buffer switch goes through atom capture. cursors({ 'xxx', 'yyy' }, 'Q') eq(1, ncursors()) -- One cursor in this buffer (other buf keeps its own). feed('jx') -- Only the current buffer's cursors cascade. eq({ 'xx', 'yy' }, get_lines()) command('buffer #') eq(1, ncursors()) eq({ 'aaa', 'bbb' }, get_lines()) -- The other buffer was not touched. end) it('entering a buffer with mcursors via a nav mapping keeps them', function() -- A navigation mapping ("nnoremap l") ending in another buffer must not count as -- "the mapping edited the buffer". command('nnoremap p') fn.setline(1, { 'aaa', 'bbb' }) feed('gg0Q') -- Cursor in buf1, at the primary position. eq(1, ncursors()) command('set hidden') command('vsplit | enew') fn.setline(1, { 'xxx', 'yyy' }) feed('gg0Q') -- Cursor in buf2. eq(1, ncursors()) feed('') -- Mapped switch to win1/buf1. eq(1, ncursors()) -- buf1 cursor still exists feed('') -- and back eq(1, ncursors()) -- buf2 cursor also end) it('"qQ" is recording (register Q), not a cursor', function() -- "q" is the recording command; a stray "q" before "Q" starts recording -- into register Q (uppercase: append) instead of adding a cursor. feed('qQ') eq('Q', fn.reg_recording()) eq(0, ncursors()) feed('q') -- stop recording: "Q" creates cursors again eq('', fn.reg_recording()) feed('Q') eq(1, ncursors()) end) it('gQ restores the cleared cursors (like gv)', function() cursors({ 'aaa', 'bbb', 'ccc', 'ddd' }, 'QjQ') clear_cursors() eq(0, ncursors()) feed('gQ') eq(2, ncursors()) feed('Gx') -- the restored cursors cascade eq({ 'aa', 'bb', 'ccc', 'dd' }, get_lines()) -- The snapshot is extmark-tracked: edits in between shift it. clear_cursors() feed('ggO') -- new line on top shifts the snapshot down feed('gQ') eq({ { 1, 0 }, { 2, 0 } }, anchors()) end) it(':edit! clears the gQ snapshot', function() local fname = t.tmpname() fn.writefile({ 'aaa', 'bbb' }, fname) command('edit ' .. fname) feed('gg0QjQ') clear_cursors() command('edit!') feed('gQ') eq(0, ncursors()) -- nothing to restore: the snapshot died with the text end) it(':g//normal! Q places a cursor at each match', function() fn.setline(1, { 'foo a', 'bar b', 'foo c', 'baz d', 'foo e' }) command('g/foo/normal! Q') eq(3, ncursors()) feed('A!') -- edit applies once per line (primary ends on the last match) eq({ 'foo a!', 'bar b', 'foo c!', 'baz d', 'foo e!' }, get_lines()) end) it('Q in operator-pending mode aborts the operator', function() -- nv_Q: checkclearop() clears the pending operator, no cursor. fn.setline(1, { 'one two' }) feed('gg0') feed('dQ') eq({ 'one two' }, get_lines()) eq(0, ncursors()) feed('wx') -- "w" moves (the "d" is gone), then x deletes one char eq({ 'one wo' }, get_lines()) end) it('Q in Visual mode adds a cursor on each selected line', function() fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('ggvjQ') -- selection spans lines 1-2 eq('n', fn.mode()) -- Visual mode ended eq({ 1, 0 }, api.nvim_win_get_cursor(0)) -- primary: first selected line eq(2, ncursors()) -- one per selected line, including under the primary feed('x') -- edits both lines eq({ 'aa', 'bb', 'ccc' }, get_lines()) -- Cursors align by screen column, not byte column: a multibyte char before the cursor -- on one line must not shift the cursors on the other lines. clear_cursors() api.nvim_buf_set_lines(0, 0, -1, true, { 'é123', 'abcdef' }) feed('gg0llvjQ') -- Visual from "2" (line 1) down; cursor ends on "c" (screen column 3) eq({ 1, 3 }, api.nvim_win_get_cursor(0)) -- primary: on "2", not mid-"é" feed('x') eq({ 'é13', 'abdef' }, get_lines()) end) it('Q then non-moving edit applies once (cursor merges into primary)', function() fn.setline(1, { 'ab' }) feed('Q') eq(1, ncursors()) feed('x') eq({ 'b' }, get_lines()) eq(0, ncursors()) -- merged at the cascade; multicursor mode ended end) it('Q on an existing cursor removes it (toggle)', function() fn.setline(1, { 'aaa', 'bbb' }) feed('Q') eq(1, ncursors()) feed('Q') -- toggle off: multicursor mode ends eq(0, ncursors()) feed('QjQk') eq(2, ncursors()) feed('Q') -- removes only the cursor under the primary eq(1, ncursors()) feed('x') -- the remaining cursor still cascades eq({ 'aa', 'bb' }, get_lines()) end) it('Q not allowed in a macro (recording or executing)', function() -- |mcursor-limitations|: Q beeps (no cursor) while recording or executing a macro. fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('gg0') feed('qq') feed('Q') -- recording: not allowed (but still recorded) feed('q') eq(0, ncursors()) feed('@q') -- executing the recorded "Q": not allowed either eq(0, ncursors()) feed('Q') -- outside a macro: works eq(1, ncursors()) end) it('1Q then Q mixes both cursor sets', function() fn.setline(1, { 'foo bar foo' }) feed('gg0*') -- sets the last search pattern; the cursor lands on the second "foo" feed('1Q') eq(2, ncursors()) -- both "foo"s, including under the primary feed('0fb') feed('Q') eq(3, ncursors()) feed('$') -- the primary edits a fourth position feed('x') eq({ 'oo ar o' }, get_lines()) end) end) describe('mouse', function() it(' toggles a cursor at the click, without moving the primary', function() command('set mousetime=0') -- repeated clicks must not count as double-clicks fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('gg0') api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) -- ctrl-click on line 3 api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) -- The primary did not move; a cursor was added at left-click. eq(1, fn.line('.')) eq({ { 2, 0 } }, anchors()) feed('x') -- cascades to the clicked position eq({ 'aa', 'bbb', 'cc' }, get_lines()) -- CTRL-click on the existing cursor removes it (toggle). api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) eq(0, ncursors()) -- CTRL-click keeps "q=" follow-mode (unlike Q). feed('ggQj') feed('q=') api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) feed('l') eq({ { 0, 1 }, { 2, 1 } }, anchors()) -- Still following: the motion cascaded. feed('q=') -- Off. -- No-op in Insert mode. feed('i') api.nvim_input_mouse('left', 'press', 'C', 0, 0, 1) api.nvim_input_mouse('left', 'release', 'C', 0, 0, 1) eq(2, ncursors()) eq(2, fn.line('.')) eq('i', fn.mode()) feed('') end) it('middle-click paste applies once', function() -- Fake clipboard provider: "*" must not touch the real system clipboard. clear('--cmd', 'set rtp^=test/functional/fixtures') fn.setline(1, { 'abc', 'def', 'ghi' }) fn.setreg('*', 'NEW') -- middle-click pastes the * register feed('gg0') api.nvim_input_mouse('middle', 'press', '', 0, 2, 0) api.nvim_input_mouse('middle', 'release', '', 0, 2, 0) feed('') -- Sanity: middle-click pastes (no cursors yet). local _, pasted = table.concat(get_lines(), '\n'):gsub('NEW', '') eq(1, pasted) feed('ggQjQ2j') -- cursors on lines 1-2, primary below api.nvim_input_mouse('middle', 'press', '', 0, 0, 0) api.nvim_input_mouse('middle', 'release', '', 0, 0, 0) feed('') -- One more paste at the click position; no paste at the other cursors. _, pasted = table.concat(get_lines(), '\n'):gsub('NEW', '') eq(2, pasted) end) end) describe('normal-mode cascade', function() it('CTRL-C interrupts the cascade; one "u" undoes the partial edit', function() local nlines = 5000 local lines = {} ---@type string[] for i = 1, nlines do lines[i] = 'aaa' end api.nvim_buf_set_lines(0, 0, -1, true, lines) api.nvim_win_set_cursor(0, { 1, 0 }) for i = 2, nlines do api.nvim_mcursor(0, { i, 0 }) end eq(nlines - 1, ncursors()) -- Interrupt from in-process on_key handler. The cascade runs too fast for test-runner CTRL-C. n.exec_lua(function() _G.keys_seen, _G.cascading_seen = 0, false vim.on_key(function() _G.keys_seen = _G.keys_seen + 1 _G.cascading_seen = _G.cascading_seen or vim.api.nvim__mcursor_cascading() if _G.keys_seen == 1000 then vim.fn.interrupt() end end) end) feed('x') -- Primary edit + cascade over ~5000 cursors... n.poke_eventloop() eq(true, n.exec_lua('return _G.cascading_seen')) local edited, unedited = 0, 0 for _, l in ipairs(get_lines()) do if l == 'aa' then edited = edited + 1 elseif l == 'aaa' then unedited = unedited + 1 end end eq(nlines, edited + unedited) -- no line was half-edited eq(true, edited >= 1) -- the primary's own edit happened eq(true, unedited >= 1) -- >=1 cursor did NOT cascade -- The partial cascade is still one undo block. feed('u') eq(lines, get_lines()) end) it('successive operations update cursor positions', function() cursors({ 'AAAA', 'BBBB', 'CCCC' }, 'QjQ') feed('jx') eq({ 'AAA', 'BBB', 'CCC' }, get_lines()) feed('x') eq({ 'AA', 'BB', 'CC' }, get_lines()) end) it('cursor positions follow line insertions/deletions', function() -- "o" inserts a line at each cursor; cursors below must shift. cursors({ 'aaa', 'bbb', 'ccc' }) feed('oX') eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) -- The primary cursor also shifted (2 lines were inserted above it). eq(6, fn.line('.')) end) it('CTRL-D scrolling does not affect the other cursors', function() local screen = Screen.new(30, 10) local l = {} for i = 1, 50 do l[#l + 1] = ('line%d'):format(i) end cursors(l) -- Scrolling is viewport-dependent, not repeatable. Other cursors do not move, even in -- follow-mode. feed('') eq({ { 0, 0 }, { 1, 0 } }, anchors()) feed('q=') feed('') feed('q=') eq({ { 0, 0 }, { 1, 0 } }, anchors()) feed('') -- An edit still cascades to the off-screen cursors, and the viewport stays anchored -- (replays scroll the window; the cascade restores it). feed('x') eq({ 'ine1', 'ine2' }, { get_lines()[1], get_lines()[2] }) screen:expect([[ line5 | line6 | ^ine7 | line8 | line9 | line10 | line11 | line12 | line13 | multicursor: ...ow motion off | ]]) end) end) describe('. (dot-repeat)', function() it('repeats operators, pre-cursor edits, inserts and changes at all cursors', function() -- Operator. cursors({ 'aaa', 'bbb', 'ccc' }) feed('x') eq({ 'aa', 'bb', 'cc' }, get_lines()) feed('.') eq({ 'a', 'b', 'c' }, get_lines()) -- An edit made before placing cursors repeats at all of them. clear_cursors() api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb', 'ccc' }) feed('gg0') feed('x') eq({ 'aa', 'bbb', 'ccc' }, get_lines()) feed('QjQj') feed('.') eq({ 'a', 'bb', 'cc' }, get_lines()) -- Insert. clear_cursors() cursors({ 'aaa', 'bbb' }, 'Qj') feed('iZ') eq({ 'Zaaa', 'Zbbb' }, get_lines()) feed('.') eq({ 'ZZaaa', 'ZZbbb' }, get_lines()) -- Change, with a q= move between the change and the repeat. clear_cursors() cursors({ 'one two', 'one two' }, 'Qj') feed('cwX') eq({ 'X two', 'X two' }, get_lines()) feed('q=') feed('w') feed('q=') feed('.') eq({ 'X X', 'X X' }, get_lines()) end) end) describe('per-cursor registers', function() it('yy/p roundtrips each cursor through its own register', function() cursors({ 'aaa', 'bbb' }, 'Qj') feed('yy') feed('p') eq({ 'aaa', 'aaa', 'bbb', 'bbb' }, get_lines()) end) it('empty at cursor init restores as empty', function() fn.setline(1, { 'one two', 'three four' }) feed('gg0') feed('Q') -- this cursor's snapshot: @a is EMPTY feed('j0') command([[let @a = 'LEAK']]) -- primary-only write (synthetic: no cascade) feed('"ap') -- The replay loads the other cursor's register snapshot, where @a was empty: nothing -- pastes there (empty registers are omitted; the primary's @a must not leak through). eq({ 'one two', 'tLEAKhree four' }, get_lines()) end) it('dW + p swaps words using each cursor register', function() cursors({ 'one two', 'three four' }, 'Qj') feed('q=') feed('dW') eq({ 'two', 'four' }, get_lines()) feed('E') feed('p') feed('q=') eq({ 'twoone ', 'fourthree ' }, get_lines()) end) it('clearing joins/gathers the per-cursor registers (DWIM)', function() cursors({ 'one two', 'three four' }, 'Qj') feed('dW') eq({ 'two', 'four' }, get_lines()) clear_cursors() -- On exit, each cursor's delete is joined (document order) into '"'. eq('one \nthree \n', fn.getreg('"')) feed('Gop') eq({ 'two', 'four', '', 'one ', 'three ' }, get_lines()) -- A cursor whose replays never wrote a register contributes nothing: it has no snapshot, -- so the pre-session '"' cannot be joined in as if it were that cursor's delete. fn.setreg('"', 'STALE') cursors({ '', 'abc' }, 'Qj') feed('x') -- writes a register at the primary only: "x" on the cursor's empty line is a no-op clear_cursors() eq('a', fn.getreg('"')) -- No contributions to join; primary's register is untouched. eq('v', fn.getregtype('"')) -- Partial-range clear deletes cursors without ending the session; no join/gather (yet). cursors({ 'one two', 'three four', 'five six' }) feed('dW') eq({ 'two', 'four', 'six' }, get_lines()) n.exec_lua( [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, 1)]] ) eq('five ', fn.getreg('"')) -- Primary's own delete, unjoined. clear_cursors() eq('three \nfive \n', fn.getreg('"')) -- All cursors cleared => yanks joined. end) it('visual p pastes each cursor register over the selection', function() cursors({ 'aaa X', 'bbb Y' }, 'Qj') feed('yiw') feed('q=') feed('w') feed('q=') feed('viwp') eq({ 'aaa aaa', 'bbb bbb' }, get_lines()) end) end) describe('operator matrix', function() it('operators cascade at each cursor', function() assert_rows({ -- x deletes a char at each cursor. { lines = { 'aaa', 'bbb', 'ccc' }, keys = 'QjQjx', expect = { 'aa', 'bb', 'cc' } }, -- dw deletes a word at each cursor. { lines = { 'hello world', 'foo bar', 'one two' }, keys = 'QjQjdw', expect = { 'world', 'bar', 'two' }, }, -- 2x deletes 2 chars at each cursor. { lines = { 'aaaa', 'bbbb', 'cccc' }, keys = 'QjQj2x', expect = { 'aa', 'bb', 'cc' } }, -- dd deletes a line at each cursor. { lines = { 'a1', 'a2', 'b1', 'b2', 'c1', 'c2' }, keys = 'Q2jQ2jdd', expect = { 'a2', 'b2', 'c2' }, }, -- D deletes to EOL, C changes to EOL. { lines = { 'one two', 'three four' }, place = 'gg0ll', keys = 'QjD', expect = { 'on', 'th' }, }, { lines = { 'one two', 'three four' }, place = 'gg0ll', keys = 'QjCX', expect = { 'onX', 'thX' }, }, -- 2cl changes 2 chars. { lines = { 'abcd', 'efgh' }, keys = 'Qj2clXY', expect = { 'XYcd', 'XYgh' } }, -- ~ toggles case and advances. { lines = { 'abc', 'def' }, keys = 'Qj~~', expect = { 'ABc', 'DEf' } }, -- cT{char} changes backwards. { lines = { 'x_abcY', 'w_defgZ' }, place = 'gg$', keys = 'Qj$cT_M', expect = { 'x_MY', 'w_MZ' }, }, -- d4h deletes backwards. { lines = { 'abcdef', 'ghijkl' }, place = 'gg$', keys = 'Qj$d4h', expect = { 'af', 'gl' } }, -- c2aw changes counted text objects. { lines = { 'one two three', 'foo bar baz' }, keys = 'Qjc2awX', expect = { 'Xthree', 'Xbaz' }, }, -- ci( on empty parens inserts inside. { lines = { 'a()b', 'c()d' }, place = 'gg0l', keys = 'Qjci(X', expect = { 'a(X)b', 'c(X)d' }, }, -- ci" seeks forward to the quotes. { lines = { 'x "aa" y', 'z "bbb" w' }, keys = 'Qjci"NEW', expect = { 'x "NEW" y', 'z "NEW" w' }, }, -- df{char} cascades with its payload char. { lines = { 'ab,cd', 'wxy,z' }, keys = 'Qjdf,', expect = { 'cd', 'z' } }, -- [count]r{char} replaces. { lines = { 'abc', 'def' }, keys = 'Qj2rZ', expect = { 'ZZc', 'ZZf' } }, -- r and gr{char}: self-terminating replace sessions (no trailing ) cascade. { lines = { 'abcd', 'efgh' }, place = 'gg0l', keys = 'Qjr', expect = { 'a', 'cd', 'e', 'gh' }, }, { lines = { 'abc', 'def' }, keys = 'QjgrZ', expect = { 'Zbc', 'Zef' } }, -- x deletes one multibyte char at each cursor. { lines = { 'éàü', '日本語' }, keys = 'Qjx', expect = { 'àü', '本語' } }, -- diw leaves the cursor at the deleted region (the "x" probes every cursor). { lines = { 'foo bar', 'baz qux', 'aaa bbb' }, place = 'gg0l', keys = 'QjQjdiw', expect = { ' bar', ' qux', ' bbb' }, after = 'x', after_expect = { 'bar', 'qux', 'bbb' }, }, -- guiW lowercases and leaves the cursor at the region start. { lines = { 'FOO BAR', 'BAZ QUX' }, place = 'gg0l', keys = 'QjguiW', expect = { 'foo BAR', 'baz QUX' }, after = 'x', after_expect = { 'oo BAR', 'az QUX' }, }, -- An operator with a search-motion payload cascades whole (the atom IS the redobuff); -- each cursor finds its own match. { lines = { 'aaa find end', 'bbb find end' }, keys = 'Qjd/find', expect = { 'find end', 'find end' }, }, -- cc preserves per-line indent with 'autoindent'. { lines = { ' aaa', ' bbb' }, pre = 'set autoindent', keys = 'QjccX', expect = { ' X', ' X' }, }, -- >> indents the line at each cursor. { lines = { 'foo', 'bar' }, pre = 'set shiftwidth=2', keys = 'Qj>>', expect = { ' foo', ' bar' }, }, }) end) end) describe('[count]Q (search matches)', function() it('places a cursor at each match of the last search pattern', function() fn.setline(1, { 'foo bar foo', 'baz foo qux', 'foobar foo' }) feed('gg0') -- on the first "foo" feed('*') -- whole-word pattern; the cursor moves to the next match feed('1Q') -- 4 whole-word "foo" matches ("foobar" excluded), including under the primary. eq(4, ncursors()) -- The primary cursor does not move ("*" left it on the second match). eq({ 1, 8 }, api.nvim_win_get_cursor(0)) feed('cwXXX') eq({ 'XXX bar XXX', 'baz XXX qux', 'foobar XXX' }, get_lines()) -- The cursor under the primary merged at the cascade (no double-apply). eq(3, ncursors()) -- A "/" search likewise, also with several matches per line. clear_cursors() api.nvim_buf_set_lines(0, 0, -1, true, { 'ab ab ab', 'xx ab' }) feed('gg0/ab') -- the cursor lands on the second "ab" feed('1Q') eq(4, ncursors()) feed('x') eq({ 'b b b', 'xx b' }, get_lines()) -- Placement uses the real search engine, so it matches what "n" finds under the current -- case options. 'ignorecase': "/foo" matches all three cases. clear_cursors() command('set ignorecase') api.nvim_buf_set_lines(0, 0, -1, true, { 'Foo foo FOO' }) feed('gg0/foo') feed('1Q') eq(3, ncursors()) feed('gUiw') eq({ 'FOO FOO FOO' }, get_lines()) -- 'smartcase': an uppercase letter in the pattern forces case-sensitivity, so only the -- exact-case match is a cursor (matchbufline would have matched all three). clear_cursors() command('set smartcase') api.nvim_buf_set_lines(0, 0, -1, true, { 'Foo foo Foo' }) feed('gg0/Foo') -- only the two "Foo"s, not "foo" feed('1Q') eq(2, ncursors()) feed('x') eq({ 'oo foo oo' }, get_lines()) end) it('does nothing without a previous search (E35)', function() fn.setline(1, { 'foo foo' }) feed('1Q') eq(0, ncursors()) end) end) describe('g CTRL-A (counter)', function() it('inserts an ascending number at each cursor', function() cursors({ 'a', 'b', 'c' }, 'Qj0Qj0') feed('g') eq({ '1a', '2b', '3c' }, get_lines()) -- A count sets the starting number. clear_cursors() cursors({ 'a', 'b' }, 'Qj0') feed('5g') eq({ '5a', '6b' }, get_lines()) -- The primary sitting ON a cursor (no cascade ran in between, so mc_dedupe did not): -- the coincident pair shares one number slot, and the cursor survives. clear_cursors() cursors({ 'x', 'y', 'z' }, 'QjQj') feed('gg0g') eq({ '1x', '2y', 'z' }, get_lines()) eq(2, ncursors()) end) it('via a mapping applies ONCE (cursor-global: no avalanche)', function() cursors({ 'a', 'b', 'c' }) command('nnoremap ,n g') feed(',n') eq({ '1a', '2b', '3c' }, get_lines()) end) it('without cursors, g CTRL-A is not a command', function() fn.setline(1, { 'x 5' }) feed('gg0g') eq({ 'x 5' }, get_lines()) -- beeps, no counter and no increment feed('') -- plain CTRL-A increments as usual eq({ 'x 6' }, get_lines()) end) it('number() takes start/step/format', function() cursors({ 'x', 'x', 'x' }, 'Qj0Qj0') n.exec_lua([[require('vim._core.mcursor').number(10, 2, '%d) ')]]) eq({ '10) x', '12) x', '14) x' }, get_lines()) end) end) describe('nvim_mcursor()', function() it('adds a cursor at (row, col), which cascades', function() fn.setline(1, { 'aaa', 'bbb', 'ccc' }) eq(1, api.nvim_mcursor(0, { 1, 0 })) eq(2, api.nvim_mcursor(0, { 2, 0 })) feed('Gx') eq({ 'aa', 'bb', 'cc' }, get_lines()) -- Can add mcursor to a hidden buffer. command('set hidden') local other = api.nvim_create_buf(true, false) api.nvim_buf_set_lines(other, 0, -1, true, { 'xxx', 'yyy' }) eq(3, api.nvim_mcursor(other, { 1, 0 })) api.nvim_set_current_buf(other) feed('Gx') eq({ 'xx', 'yy' }, get_lines()) end) it('rejects invalid positions', function() fn.setline(1, { 'aaa' }) t.matches('Invalid cursor line: out of range', t.pcall_err(api.nvim_mcursor, 0, { 99, 0 })) t.matches( "Invalid 'pos': expected %[row, col%] array", t.pcall_err(api.nvim_mcursor, 0, { 1 }) ) t.matches('Invalid buffer', t.pcall_err(api.nvim_mcursor, 9999, { 1, 0 })) end) it('deleting a cursor extmark deletes the cursor', function() cursors({ 'aaa', 'bbb', 'ccc' }) local ns = api.nvim_create_namespace('nvim.multicursor') local marks = api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) eq(2, #marks) api.nvim_buf_del_extmark(0, ns, marks[1][1]) feed('x') -- the cursor without a mark is swept; the other cascades eq({ 'aaa', 'bb', 'cc' }, get_lines()) eq(1, ncursors()) end) it('exit with cursors active in multiple buffers', function() cursors({ 'aaa', 'bbb' }, 'Qj') feed('yy') -- Session-written register, exit path reaches mc_reg_gather. command('new') fn.setline(1, { 'ccc' }) api.nvim_mcursor(0, { 1, 0 }) n.expect_exit(command, 'qall!') end) it('cursors are freed with their buffer', function() fn.setline(1, { 'aaa', 'bbb' }) local buf = api.nvim_get_current_buf() eq(1, api.nvim_mcursor(0, { 1, 0 })) eq(2, api.nvim_mcursor(0, { 2, 0 })) command('new') fn.setreg('"', 'KEEP') command('bwipeout! ' .. buf) -- Registers NOT gathered/joined, the cleared cursors are not in curbuf. eq('KEEP', fn.getreg('"')) eq('v', fn.getregtype('"')) fn.setline(1, { 'xxx', 'yyy' }) -- The wiped buffer's cursors are gone: only the new one counts. eq(1, api.nvim_mcursor(0, { 1, 0 })) feed('Gx') eq({ 'xx', 'yy' }, get_lines()) end) end) describe('buffers and windows', function() it('cascade is per-buffer: pauses in another buffer, resumes on return', function() -- Cursors cascade only if their buffer is the current buffer. command('set hidden') cursors({ 'aaa', 'bbb' }, 'Qj') command('enew') fn.setline(1, { 'xxx' }) feed('gg0x') -- no cascade into the other buffer eq({ 'xx' }, get_lines()) eq(0, ncursors()) command('buffer #') eq({ 'aaa', 'bbb' }, get_lines()) eq(1, ncursors()) -- the extmark-tracked cursor survived the round-trip feed('2G0x') eq({ 'aa', 'bb' }, get_lines()) end) it('each buffer has its own cursor set', function() command('set hidden') fn.setline(1, { 'aaa', 'bbb' }) local buf_a = api.nvim_get_current_buf() feed('gg0Q') feed('j') command('new') local buf_b = api.nvim_get_current_buf() cursors({ 'xxx', 'yyy' }, 'Qj') eq(1, ncursors()) feed('x') -- cascades only in the current buffer eq({ 'xx', 'yy' }, get_lines()) eq({ 'aaa', 'bbb' }, api.nvim_buf_get_lines(buf_a, 0, -1, true)) command('wincmd p') feed('2G0x') eq({ 'aa', 'bb' }, get_lines()) eq({ 'xx', 'yy' }, api.nvim_buf_get_lines(buf_b, 0, -1, true)) end) it('edits cascade from any window showing the buffer (:split)', function() -- Cascade is conditional on the buffer, not the window. cursors({ 'aaa', 'bbb' }, 'Q') command('split') feed('jx') -- edit from the new window eq({ 'aa', 'bb' }, get_lines()) end) it('clearing is per-buffer', function() command('set hidden') cursors({ 'aaa', 'bbb' }, 'Qj') command('enew') clear_cursors() command('buffer #') eq(1, ncursors()) clear_cursors() eq(0, ncursors()) feed('gg0x') -- only the primary edits eq({ 'aa', 'bbb' }, get_lines()) end) it('nvim_buf_clear_namespace("nvim.multicursor") deletes the cursors', function() cursors({ 'aaa', 'bbb' }, 'Qj') eq(1, ncursors()) n.exec_lua([[ vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, -1) ]]) eq(0, ncursors()) feed('gQ') -- the deletion snapshotted the cursors: restorable eq(1, ncursors()) feed('x') -- both cursors edit again (primary still on line 2) eq({ 'aa', 'bb' }, get_lines()) end) it(':edit! reload clears the cursors', function() local fname = t.tmpname() fn.writefile({ 'aaa', 'bbb' }, fname) command('edit ' .. fname) feed('gg0Q') feed('j') eq(1, ncursors()) command('edit!') eq(0, ncursors()) feed('Q') -- A new session starts cleanly. eq(1, ncursors()) end) end) describe('insert-mode cascade', function() it('CTRL-U cascades before (deletion crossing the session anchor)', function() -- Deleting typed text cascades live (the region shrinks). But CTRL-U here eats the "o" -- autoindent, which precedes the tracked region, invisible to the preview diff. -- Non-literal keys flush instead: the pending keys replay as a span immediately. command('set autoindent') cursors({ ' indented aa', ' indented cc' }, 'Q') feed('2gg0') feed('o') feed('') -- Mid-session (no yet): the cursor's line must match the -- primary's (indent eaten at both). eq({ ' indented aa', '', ' indented cc', '' }, get_lines()) end) it('o + CTRL-U does not join lines at less-indented cursors', function() -- 'autoindent': "o" opens an indented line at the indented cursors, an empty line at the flat -- cursor. The replayed CTRL-U there has nothing to delete; it must not backspace through the -- line boundary ('backspace' includes "eol") and join lines. command('set autoindent') cursors({ ' indented aa', 'flat bb', ' indented cc' }) feed('oyay') eq({ ' indented aa', '\tyay', 'flat bb', '\tyay', ' indented cc', '\tyay', }, get_lines()) end) it('cursor-moves cascade live at each cursor', function() cursors({ 'alpha one', 'beta two', 'gamma three' }) -- A cursor-move key is a cmd, not text: it flushes pending keys as a span (like CTRL-U). feed('AabX') -- Mid-session (no yet): the cursor-move already applied everywhere. eq({ 'alpha oneXab', 'beta twoXab', 'gamma threeXab' }, get_lines()) feed('') feed('AZY') eq({ 'Yalpha oneXabZ', 'Ybeta twoXabZ', 'Ygamma threeXabZ' }, get_lines()) -- Word-wise cursor-move: per-cursor word boundaries, not a copied column offset. feed('AW') eq({ 'Yalpha WoneXabZ', 'Ybeta WtwoXabZ', 'Ygamma WthreeXabZ' }, get_lines()) end) it('CTRL-G U cursor-move cascades live; one undo block', function() cursors({ 'aa', 'bb' }, 'Qj') feed('i12U3') -- Mid-session: the CTRL-G U move flushed and cascaded. eq({ '132aa', '132bb' }, get_lines()) feed('') -- CTRL-G U does not split undo (unlike plain ). feed('u') eq({ 'aa', 'bb' }, get_lines()) end) it('absolute jump () splits: previews stay, the live cascade re-anchors', function() fn.setline(1, { 'alpha', 'beta', 'gamma' }) feed('j0Q') feed('j0Q') feed('gg$') feed('ixy') -- Mid-session: "x" stays as text; "y" (after jump) continues at the re-anchored positions. eq({ 'yalphxa', 'xybeta', 'xygamma' }, get_lines()) feed('') eq({ 'yalphxa', 'xybeta', 'xygamma' }, get_lines()) end) it('CTRL-C ends the session like : text and cursors survive', function() command('set autoindent') cursors({ ' indented aa', 'flat bb', ' indented cc' }) feed('oyay') -- Drain first: a CTRL-C pending in typeahead cancels the queued keys before they execute -- (unrelated to multicursor). n.poke_eventloop() feed('') -- CTRL-C ended the insert session; it must not also abort the commit replay (the primary -- keeps its text on CTRL-C, so the cursors do too). eq({ ' indented aa', '\tyay', 'flat bb', '\tyay', ' indented cc', '\tyay', }, get_lines()) -- The cursors survive: a subsequent edit still cascades to all three. feed('x') eq({ ' indented aa', '\tya', 'flat bb', '\tya', ' indented cc', '\tya', }, get_lines()) end) it('EOL insertion points display as virtual cells', function() local screen = Screen.new(40, 5) command('hi MCursor guifg=NONE guibg=Red') cursors({ 'aa', 'bbbb', 'cc' }) -- "A": each insertion point is past EOL (no char cell), a virtual cell overlays it. #41576 feed('A') screen:add_extra_attr_ids({ [100] = { background = Screen.colors.Red }, }) screen:expect([[ aa{100: } | bbbb{100: } | cc^ | {1:~ }| {5:-- INSERT --} | ]]) -- Ending the session moves cursors onto last char (real text); the virtual cell disappears. feed('!') screen:expect([[ aa{100:!} | bbbb{100:!} | cc^! | {1:~ }| | ]]) end) it('typed text appears at cursors before leaving insert-mode', function() local screen = Screen.new(30, 6) cursors({ 'aaa', 'bbb', 'ccc' }) -- Still in insert mode (no yet): the text already cascaded; each cursor displays -- at its insertion point, like the primary. feed('iXY') screen:expect([[ XY{17:a}aa | XY{17:b}bb | XY^ccc | {1:~ }|*2 {5:-- INSERT --} | ]]) feed('') eq({ 'XYaaa', 'XYbbb', 'XYccc' }, get_lines()) -- The session end shifts every cursor onto the last inserted char. screen:expect([[ X{17:Y}aaa | X{17:Y}bbb | X^Yccc | {1:~ }|*2 | ]]) -- Entering insert mode displays each cursor at its insertion point -- right away, BEFORE any text is typed ("a": one char to the right). feed('a') screen:expect([[ XY{17:a}aa | XY{17:b}bb | XY^ccc | {1:~ }|*2 {5:-- INSERT --} | ]]) feed('') -- An operator entry ("ciw") live-mirrors the same way: the entry -- replay changes each cursor's OWN word, still in insert mode. feed('0ciwZ') screen:expect([[ Z{17: } | Z{17: } | Z^ | {1:~ }|*2 {5:-- INSERT --} | ]]) feed('') -- A Visual-entered change ("viwc") live-mirrors too: the entry replay -- re-executes the selection at each cursor. feed('viwcW') screen:expect([[ W{17: } | W{17: } | W^ | {1:~ }|*2 {5:-- INSERT --} | ]]) feed('') end) end) describe('insert-mode depth', function() it('session survives all cursors deduping away mid-session', function() -- A cursor at the primary's position: the "A" entry replay lands it on the primary and -- dedupe removes it mid-session; the commit must not cascade into the empty set. fn.setline(1, { 'x' }) feed('Q') feed('Ahi') n.assert_alive() eq({ 'xhi' }, get_lines()) end) it('insert sessions cascade at each cursor', function() assert_rows({ -- iZ: after the cursors sit ON the last inserted char (like the primary cursor). { lines = { 'aaa', 'bbb', 'ccc' }, keys = 'QjQjiZ', expect = { 'Zaaa', 'Zbbb', 'Zccc' }, after = 'x', after_expect = { 'aaa', 'bbb', 'ccc' }, }, -- A: distinct chars and line lengths, so an off-by-one anchor (e.g. a past-EOL anchor -- clamped by a replay in MODE_NORMAL) inserts before the last char, which identical -- chars ("!!") would mask. { lines = { 'a', 'bb bb', 'c cc' }, keys = 'QjQjA!?', expect = { 'a!?', 'bb bb!?', 'c cc!?' }, after = 'x', after_expect = { 'a!', 'bb bb!', 'c cc!' }, }, -- cw changes a word at each cursor. { lines = { 'aaa one', 'bbb two', 'ccc three' }, keys = 'QjQjcwFOO', expect = { 'FOO one', 'FOO two', 'FOO three' }, }, -- cascades deletion. { lines = { 'aaa', 'bbb' }, keys = 'QjiXYZ', expect = { 'XZaaa', 'XZbbb' } }, -- deletes before the insert start. { lines = { 'abc', 'def' }, place = 'gg0l', keys = 'QjiZ', expect = { 'Zbc', 'Zef' }, }, -- at col 0 joins with the line above. { lines = { 'aa', 'bb', 'cc', 'dd' }, place = 'ggj0', keys = 'Q2ji', expect = { 'aabb', 'ccdd' }, }, -- mid-insert splits the line. { lines = { 'aaXbb', 'ccXdd' }, place = 'gg02l', keys = 'QjiABCD', expect = { 'aaAB', 'CDXbb', 'ccAB', 'CDXdd' }, }, -- O opens a line above. { lines = { 'aaa', 'bbb', 'ccc' }, keys = 'Q2jOX', expect = { 'X', 'aaa', 'bbb', 'X', 'ccc' }, }, -- a appends after the cursor char. { lines = { 'abc', 'def' }, keys = 'QjaZ', expect = { 'aZbc', 'dZef' } }, -- I inserts at the first non-blank. { lines = { ' aa', ' bb' }, place = 'gg$', keys = 'Qj$IX', expect = { ' Xaa', ' Xbb' }, }, -- i_CTRL-W deletes a word. { lines = { 'zz', 'yy' }, keys = 'Qjifoo barX', expect = { 'foo Xzz', 'foo Xyy' }, }, -- i_CTRL-V inserts a unicode char; after the cursors sit ON it. { lines = { 'aaa', 'bbb' }, keys = 'Qjiu00e9', expect = { 'éaaa', 'ébbb' }, after = 'x', after_expect = { 'aaa', 'bbb' }, }, -- i at col 0 keeps the cursor at col 0. { lines = { 'abc', 'def' }, keys = 'Qji', expect = { 'abc', 'def' }, after = 'x', after_expect = { 'bc', 'ef' }, }, -- 3iZ (counted insert) cascades the whole session. { lines = { 'aaa', 'bbb' }, keys = 'Qj3iZ', expect = { 'ZZZaaa', 'ZZZbbb' } }, -- R replaces; R + restores the replaced chars. { lines = { 'abcdef', 'ghijkl' }, keys = 'QjRXY', expect = { 'XYcdef', 'XYijkl' } }, { lines = { 'abcdef', 'ghijkl' }, keys = 'QjRXY', expect = { 'abcdef', 'ghijkl' }, }, -- Abbreviations expand identically at each cursor. { lines = { 'aaa', 'bbb' }, pre = 'iabbrev teh the', keys = 'Qjiteh ', expect = { 'the aaa', 'the bbb' }, }, -- 'autoindent' applies per cursor with o. { lines = { ' aa', ' bb' }, pre = 'set autoindent', keys = 'QjoX', expect = { ' aa', ' X', ' bb', ' X' }, }, }) end) it('ea appends at word end at each cursor (with q=)', function() cursors({ 'one two', 'three four' }, 'Qj') feed('q=') feed('ea!') feed('q=') eq({ 'one! two', 'three! four' }, get_lines()) end) it('i_CTRL-N completion result appears at each cursor', function() fn.setline(1, { 'wombat', 'wo', 'wo' }) feed('2gg') feed('Q') feed('j') feed('A') eq({ 'wombat', 'wombat', 'wombat' }, get_lines()) end) end) describe('navigation state (primary-only)', function() it('jumplist, changelist and marks follow only the primary', function() local l = {} for i = 1, 30 do l[#l + 1] = ('word%d line'):format(i) end cursors(l) -- Change marks and changelist: one edit at 3 cursors records the primary's position only. local nchanges = #fn.getchangelist()[1] feed('x') eq({ 3, 0 }, api.nvim_buf_get_mark(0, '[')) eq({ 3, 0 }, api.nvim_buf_get_mark(0, ']')) eq({ 3, 0 }, api.nvim_buf_get_mark(0, '.')) eq(nchanges + 1, #fn.getchangelist()[1]) -- Visual marks: the primary's selection. feed('viwy') eq({ 3, 0 }, api.nvim_buf_get_mark(0, '<')) eq({ 3, 3 }, api.nvim_buf_get_mark(0, '>')) -- Jumplist: a followed jump records one entry (the primary's). -- (Last: all cursors land on line 30 and merge.) command('clearjumps') feed('q=') feed('G') feed('q=') eq(1, #fn.getjumplist()[1]) end) end) describe('completion', function() -- While a completion is active, the cascade pauses: redobuff is frozen, and spans cannot replay -- into a busy completion (edit() refuses recursive insert). The other cursors catch up when the -- completion ends. --- Three empty lines under "foo*" completion candidates; cursors on lines 4-5, primary on 6. local function ac_setup() command('setlocal autocomplete') cursors({ 'foo', 'foobar', 'foobarbaz', '', '', '' }, '3jQjQj') end it("'autocomplete' popup: cursors catch up when the completion ends", function() local screen = Screen.new(40, 12) ac_setup() feed('if') feed('o') screen:expect({ any = 'INSERT' }) -- The preview updates live (even during completion); cursors intact. eq(2, ncursors()) feed('') eq({ 'foo', 'foobar', 'foobarbaz', 'fo', 'fo', 'fo' }, get_lines()) eq(2, ncursors()) end) it("'autocomplete': accepting a completion applies at all cursors", function() ac_setup() feed('if') feed('') -- select the first popup entry feed('') -- accept feed(' tail') -- and keep typing local l = get_lines() eq({ l[4], l[4] }, { l[5], l[6] }) end) it("'autocomplete': and cancel propagate", function() ac_setup() feed('ifoox') eq({ 'fox', 'fox', 'fox' }, { get_lines()[4], get_lines()[5], get_lines()[6] }) feed('cc') feed('f') -- cycle, then cancel back to the leader eq({ 'f', 'f', 'f' }, { get_lines()[4], get_lines()[5], get_lines()[6] }) end) it('InsertCharPre-driven complete() plugin (cmp-style)', function() local screen = Screen.new(40, 12) -- Typical third-party completion plugin: an InsertCharPre handler -- that schedules complete() (textlock forbids calling it directly). n.exec_lua([[ vim.api.nvim_create_autocmd('InsertCharPre', { callback = function() vim.schedule(function() if vim.fn.mode():find('i') and vim.fn.pumvisible() == 0 then vim.fn.complete(1, { 'foobar', 'foolish' }) end end) end, }) ]]) cursors({ '', '', '' }) feed('if') screen:expect({ any = 'foolish' }) -- wait for the popup feed('') -- accept the (pre-inserted) first candidate feed('') eq({ 'foobar', 'foobar', 'foobar' }, get_lines()) screen:expect({ none = 'foolish' }) -- popup closed -- with the popup still visible keeps all cursors equal too. feed('cc') feed('x') screen:expect({ any = 'foolish' }) -- wait for the new popup feed('') local l = get_lines() eq({ l[3], l[3] }, { l[1], l[2] }) end) end) describe('visual-mode cascade', function() it('failed command mid-replay does not leak Visual mode into the next replay', function() fn.setline(1, { 'alpha bravo', 'golf hotel', 'mike november' }) feed('ggVjjQ') -- cursor on each line; enables "q=" follow-motion feed('gg0') -- The abandoned selection replays "vlo h " at each cursor ("q=" follow). The "h" fails -- (col 0 after "o" swapped to the selection start), which flushes the rest of the replay, -- eating the terminating . Visual mode must not leak into the next cursor's replay. feed('vloh') eq({ 'alpha bravo', 'golf hotel', 'mike november' }, get_lines()) eq('n', api.nvim_get_mode().mode) end) it('shows per-cursor visual selection', function() local screen = Screen.new(30, 6) cursors({ 'longword x', 'ab y', 'medium z' }) -- Each cursor shows its own selection ("iw" = that cursor's word), previewed live. feed('viw') screen:expect([[ {17:longword} x | {17:ab} y | {17:mediu}^m z | {1:~ }|*2 {5:-- VISUAL --} | ]]) feed('e') screen:expect([[ {17:longword x} | {17:ab y} | {17:medium }^z | {1:~ }|*2 {5:-- VISUAL --} | ]]) feed('') screen:expect([[ {17:l}ongword x | {17:a}b y | medium ^z | {1:~ }|*2 | ]]) end) it('shows linewise/blockwise selections', function() local screen = Screen.new(30, 6) cursors({ 'aaaa', 'bbbb', 'cccc', 'dddd' }, 'Q2j') feed('Vj') -- linewise: primary lines 3-4, fake lines 1-2 screen:expect([[ {17:aaaa} | {17:bbbb} | {17:cccc} | ^d{17:ddd} | {1:~ }| {5:-- VISUAL LINE --} | ]]) feed('') feed('3G0l') feed('jl') -- blockwise: primary (3,1)-(4,2), fake (1,0)-(2,1) screen:expect([[ {17:aa}aa | {17:bb}bb | c{17:cc}c | d{17:d}^dd | {1:~ }| {5:-- VISUAL BLOCK --} | ]]) end) it('replays the full visual keysequence', function() cursors({ 'one two three x', 'aa bb cc d' }, 'Qj') -- Select word, extend twice, delete: selection re-executes at each cursor, so the extents are -- per-cursor (not a fixed-size reselect). atoms_start() feed('viweex') eq({ ' x', ' d' }, get_lines()) -- The operator is normalized ("translated"): visual "x" == "d". eq({ 'viweed' }, atoms_tail(1)) end) it('operators cascade at each cursor', function() assert_rows({ -- Vjd deletes two lines. { lines = { 'a', 'b', 'c', 'd', 'e', 'f' }, keys = 'Q4jVjd', expect = { 'c', 'd' } }, -- r replaces the selection. { lines = { 'one two', 'ab cd' }, keys = 'QjviwrX', expect = { 'XXX two', 'XX cd' } }, -- Blockwise c changes the block, I inserts before it, A appends after it. { lines = { 'ab', 'cd', 'ef', 'gh' }, keys = 'Q2jjcX', expect = { 'Xb', 'Xd', 'Xf', 'Xh' }, }, { lines = { 'ab', 'cd', 'ef', 'gh' }, keys = 'Q2jjIX', expect = { 'Xab', 'Xcd', 'Xef', 'Xgh' }, }, { lines = { 'ab', 'cd', 'ef', 'gh' }, keys = 'Q2jjA!', expect = { 'a!b', 'c!d', 'e!f', 'g!h' }, }, }) end) it(' discards the pending visual atom', function() cursors({ 'abc', 'def' }, 'Qj') feed('viw') feed('x') -- cascades normally; no stray visual replay eq({ 'bc', 'de' }, get_lines()) end) it('cursor displays at each selection end; o swaps it', function() local screen = Screen.new(40, 6) command('hi MCursor guifg=NONE guibg=Red') command('hi MCursorVisual guibg=Blue') cursors({ 'aaa bbb ccc', 'ddd eee fff', 'ggg hhh iii' }, '4lQjQj') feed('vl') screen:add_extra_attr_ids({ [100] = { background = Screen.colors.Blue1 }, }) -- The (red) display cursor sits at each selection end; the (blue) -- anchor cell shows only the selection. screen:expect([[ aaa {100:b}{30:b}b ccc | ddd {100:e}{30:e}e fff | ggg {17:h}^hh iii | {1:~ }|*2 {5:-- VISUAL --} | ]]) feed('o') screen:expect([[ aaa {30:b}{100:b}b ccc | ddd {30:e}{100:e}e fff | ggg ^h{17:h}h iii | {1:~ }|*2 {5:-- VISUAL --} | ]]) feed('') -- Back to normal mode: the cursor highlight returns to the anchors. screen:expect([[ aaa {30:b}bb ccc | ddd {30:e}ee fff | ggg ^hhh iii | {1:~ }|*2 | ]]) end) it('payload motions: f{char} and search', function() cursors({ 'abcd,ef', 'wxyz,gh' }, 'Qj') feed('vf,d') -- f-operand: replayable, cascades. eq({ 'ef', 'gh' }, get_lines()) clear_cursors() cursors({ 'one two', 'one two' }, 'Qj') feed('v/twod') -- Search payload travels in the collected keys, cascades. eq({ 'wo', 'wo' }, get_lines()) clear_cursors() -- Viewport scroll (C-E) that drags the cursor (viewport edge, 'scrolloff') changes the -- selection by a viewport-dependent amount: not replayable, edits primary only. local lines = {} for i = 1, 30 do lines[i] = 'x' .. i end fn.setline(1, lines) feed('10ggQgg') feed('V') -- Drags the primary onto line 2: selection is lines 1-2. feed('d') eq(28, fn.line('$')) -- Primary deleted 2 lines; no cascade. eq({ 'x3', 'x4' }, { fn.getline(1), fn.getline(2) }) end) end) describe('q= (follow motion)', function() it('cursors follow primary-cursor motions', function() cursors({ 'abcd', 'efgh' }, 'Q') feed('j') -- No cascade/follow. feed('q=') feed('ll') -- Cascade, both cursors move 2 chars rightwards. feed('x') eq({ 'abd', 'efh' }, get_lines()) -- Toggle off: "h" moves only primary, the other cursor still deletes at its unmoved position. feed('q=') feed('h') feed('x') eq({ 'ab', 'eh' }, get_lines()) -- [count]q= forces the mode instead of toggling. feed('1q=') -- Force "on". feed('1q=') feed('h') feed('x') eq({ 'b', 'h' }, get_lines()) feed('2q=') -- Force "off". feed('2q=') feed('A!') feed('h') feed('x') eq({ 'b', '!' }, get_lines()) end) it('implicit exit (cursors deduped) resets follow-motion', function() cursors({ 'aaa', 'bbb', 'ccc' }) eq(2, ncursors()) feed('q=') feed('gg') -- Absolute motion: every cursor lands on the primary, all deduped. eq(0, ncursors()) -- Exited implicitly: "q=" resets, else the next "Q" would dedupe on "j". feed('Q') feed('j') eq(1, ncursors()) end) it('Q (placing a cursor) ends follow-mode', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'Qj') feed('q=') feed('l') -- Sanity: cascades. eq({ { 0, 1 } }, anchors()) feed('Q') -- Adds a cursor at the primary, and exits follow-mode. eq(2, ncursors()) feed('j') -- No follow: nothing converges or dedupes. eq(2, ncursors()) eq({ { 0, 1 }, { 1, 1 } }, anchors()) end) it('jumps are not followed (CTRL-O, backtick)', function() cursors({ 'abcd', 'efgh', 'ijkl' }, 'Q') feed('3G') -- Jumps fill the jumplist; no cascade. feed('2G') feed('q=') -- Follow-mode. feed('l') -- Sanity: motions cascade. eq({ { 0, 1 } }, anchors()) feed('') -- Jump. feed('``') -- Jump. eq({ { 0, 1 } }, anchors()) end) it('cursors follow j/k, gj/gk, arrow keys, and $', function() cursors({ 'a1', 'a2', 'a3', 'b1', 'b2', 'b3' }, 'Q') feed('3j') -- Cursors at "a1" and "b1". feed('q=') feed('j') -- Both cursors move down. feed('x') eq({ 'a1', '2', 'a3', 'b1', '2', 'b3' }, get_lines()) feed('k') -- Both cursors move back up. feed('x') eq({ '1', '2', 'a3', '1', '2', 'b3' }, get_lines()) feed('gj') -- Display-line motions follow too. feed('x') eq({ '1', '', 'a3', '1', '', 'b3' }, get_lines()) feed('gk') feed('x') eq({ '', '', 'a3', '', '', 'b3' }, get_lines()) -- Arrow keys follow. clear_cursors() cursors({ 'abcd', 'efgh', 'ijkl', 'mnop' }, 'Q2j') -- Cursors at "abcd" and "ijkl". feed('q=') feed('') feed('') feed('x') eq({ 'abcd', 'egh', 'ijkl', 'mop' }, get_lines()) feed('') feed('') feed('x') eq({ 'bcd', 'egh', 'jkl', 'mop' }, get_lines()) feed('q=') -- $ moves each cursor to its own EOL. clear_cursors() cursors({ 'abc', 'defgh' }, 'Qj') feed('q=') feed('$') feed('x') eq({ 'ab', 'defg' }, get_lines()) end) it('cursors follow mapped motions (nnoremap j gj)', function() command('nnoremap j gj') cursors({ 'a1', 'a2', 'b1', 'b2' }, 'Q2j') feed('q=') feed('j') feed('x') eq({ 'a1', '2', 'b1', '2' }, get_lines()) -- Expr-mapping results follow the same way. command([[nnoremap j v:count == 0 ? 'gk' : 'k']]) feed('j') feed('x') eq({ '1', '2', '1', '2' }, get_lines()) -- No follow: a motion mapping does not cascade; the "x" does. feed('q=') feed('j') -- gk: moves only the primary. feed('x') eq({ '', '', '1', '2' }, get_lines()) end) it('q= while macro-recording does not toggle', function() fn.setline(1, { 'a1', 'a2', 'b1', 'b2' }) feed('gg0') feed('Q') -- Cursor placed before recording starts. feed('qa') -- Start recording. feed('2j') feed('q=') -- q stops recording; = is a pending operator... feed('') feed('j') feed('x') -- Follow did not toggle. eq({ '1', 'a2', 'b1', '2' }, get_lines()) end) it('abandoned visual selection moves cursors to their selection ends', function() cursors({ 'aaa bbb ccc', 'ddd eee fff', 'ggg hhh iii' }, '4lQjQj') feed('q=') feed('viw') -- Selection end: the last char of each cursor's word. feed('x') eq({ 'aaa bb ccc', 'ddd ee fff', 'ggg hh iii' }, get_lines()) -- No follow: discards, other cursors stay; "x" cascades. feed('q=') feed('0viw') feed('x') eq({ 'aaa bbccc', 'ddd eefff', 'gg hh iii' }, get_lines()) end) it('per-cursor curswant is kept over short lines', function() fn.setline(1, { 'ABCDEF', 'xy', 'GHIJKL', 'MNOPQR', 'zw', 'STUVWX' }) feed('gg04l') -- Cursor at (1,4). feed('Q') feed('3jhh') -- Primary at (4,2), a different column. feed('q=') feed('jj') -- Over the short lines: each cursor keeps its column. feed('q=') feed('x') eq({ 'ABCDEF', 'xy', 'GHIJL', 'MNOPQR', 'zw', 'STVWX' }, get_lines()) end) end) describe('parity', function() it('mapped command cascades its resolved atom', function() command('nnoremap ,d dw') cursors({ 'one two aa bb', 'three four cc dd' }, 'Qj') atoms_start() feed(',d') eq({ 'two aa bb', 'four cc dd' }, get_lines()) -- With mcursors: still exactly one CmdAtom event; cascade replays do not emit CmdAtoms. eq(1, #atoms()) end) it('mapping with edits and motions cascades as one unit', function() -- Split the line at the cursor, ending at the EOL of the first half. command('nnoremap gj ik$') cursors({ 'aaa bbb', 'ccc ddd', 'eee fff' }, '4lQjQj') atoms_start() feed('gj') eq({ 'aaa ', 'bbb', 'ccc ', 'ddd', 'eee ', 'fff' }, get_lines()) -- The mapping is the atom: exactly one CmdAtom event. Never re-resolved. local evs = atoms() eq(1, #evs) eq({ type = 'mapping', lhs = 'gj', keys = k('1iik$'), changed = true }, { type = evs[1].type, lhs = evs[1].lhs, keys = evs[1].keys, changed = evs[1].changed, }) -- `atoms` is non-empty iff the atom is a composite of more than one command; local children = {} for _, c in ipairs(evs[1].atoms) do table.insert(children, { c.type, c.keys }) end eq({ { 'insert', k('1i') }, -- spans display as "insert" (cascade-internal type) { 'insert', k('i') }, { 'motion', 'k' }, { 'motion', '$' }, }, children) eq({ 'k', false }, { evs[1].atoms[3].cmd, evs[1].atoms[3].changed }) -- The mapping's motions (k$) cascade too, even without "q=", because the mapping edits. feed('x') eq({ 'aaa', 'bbb', 'ccc', 'ddd', 'eee', 'fff' }, get_lines()) end) it('operator + insert mapping cascades as one unit', function() command('nnoremap ,x dwiFOO ') cursors({ 'one two', 'three four' }, 'Qj') feed(',x') eq({ 'FOO two', 'FOO four' }, get_lines()) end) it('live insert cascade emits one whole-session atom', function() cursors({ 'aaa', 'bbb' }, 'Qj') atoms_start() -- Typed key-by-key (event-loop pulses between keys): cascades span-by-span, yet emits one -- insert-session atom. Spans are cascade-internal, never CmdAtom events. feed('i') n.poke_eventloop() feed('X') n.poke_eventloop() feed('Y') n.poke_eventloop() feed('') local evs = atoms() eq(1, #evs) eq({ type = 'insert', text = 'XY', keys = k('1iXY') }, { type = evs[#evs].type, text = evs[#evs].text, keys = evs[#evs].keys, }) -- No session marks outlive the session (mc_ins_commit() drops them, cascaded or not). eq( 0, n.exec_lua([[ local ns = vim.api.nvim_get_namespaces()['nvim.multicursor._session'] return ns and #vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) or 0 ]]) ) -- A Visual-entered session keeps its "visual" type: each nested span-replay bracket owns -- its own InsSession, so it cannot clobber the primary session's `vis`. clear_cursors() cursors({ 'alpha one', 'beta two' }, 'Qj') feed('viwcX') eq({ 'X one', 'X two' }, get_lines()) eq('visual', atom_last().type) end) it('InsertEnter/InsertLeave/TextChanged(I) fire once per action', function() command('let [g:ie, g:il, g:tci, g:tc] = [0, 0, 0, 0]') command('autocmd InsertEnter * let g:ie += 1') command('autocmd InsertLeave * let g:il += 1') command('autocmd TextChangedI * let g:tci += 1') command('autocmd TextChanged * let g:tc += 1') cursors({ 'aaa', 'bbb' }, 'Qj') -- Drain input between keys: TextChanged(I) fires only at idle. -- The expected counts below are exactly what this sequence produces WITHOUT multicursors. feed('i') n.poke_eventloop() feed('X') n.poke_eventloop() feed('Y') n.poke_eventloop() feed('') n.poke_eventloop() eq({ 'XYaaa', 'XYbbb' }, get_lines()) eq(1, api.nvim_get_var('ie')) eq(1, api.nvim_get_var('il')) eq(2, api.nvim_get_var('tci')) -- Once per typed char, not per cursor. eq(1, api.nvim_get_var('tc')) -- Once for the whole session. end) it('InsertCharPre fires once per typed char, result applies everywhere', function() command('let g:icp = 0') command('autocmd InsertCharPre * let g:icp += 1 | let v:char = toupper(v:char)') cursors({ 'aaa', 'bbb' }, 'Qj') feed('ixy') eq({ 'XYaaa', 'XYbbb' }, get_lines()) eq(2, api.nvim_get_var('icp')) end) it('TextYankPost fires per cursor with per-cursor contents', function() command('let g:yanks = []') command( 'autocmd TextYankPost * let g:yanks += [[v:event.regcontents, luaeval("vim.api.nvim__mcursor_cascading()")]]' ) cursors({ 'aaa', 'bbb' }, 'Qj') feed('yy') -- The primary's own yank fires first and is not a replay. eq({ { { 'bbb' }, false }, { { 'aaa' }, true } }, api.nvim_get_var('yanks')) -- The primary's registers win, it is the effective yank. eq('bbb\n', fn.getreg('"')) end) end) describe('yank DWIM (concat on exit)', function() it('joins per-cursor yanks (document order) into the register on exit', function() cursors({ 'foo x', 'bar y', 'baz z' }, 'Qj0Q') feed('j0') -- Cursors on lines 1,2; primary on line 3 (yanked first). feed('yiw') -- During multicursor, the register is the primary's own yank. eq('baz', fn.getreg('"')) clear_cursors() -- Exit: concat, in document-order (not yank-order). eq('foo\nbar\nbaz\n', fn.getreg('"')) eq('V', fn.getregtype('"')) -- Linewise. end) it('regular yank (no multicursor)', function() fn.setline(1, { 'hello' }) feed('gg0yiw') eq('hello', fn.getreg('"')) end) it('a named register concatenates, an untouched one is preserved', function() fn.setreg('z', 'PRESET') -- 'z' is untouched during multicursor. cursors({ 'foo', 'bar' }, 'Qj0') feed('"ayiw') clear_cursors() eq('foo\nbar\n', fn.getreg('a')) -- The used register is joined. eq('PRESET', fn.getreg('z')) -- An untouched register is not tripled. end) it('last-write-wins: delete after yank yields the deletes', function() cursors({ 'aa', 'bb' }, 'Qj0') feed('yl') -- Yank a char into '"'. feed('x') -- Delete a char into '"' (overwrites, per register semantics). clear_cursors() eq('a\nb\n', fn.getreg('"')) -- The deletes, not the yanks. end) end) describe('options', function() it("'textwidth' auto-wrap applies at each cursor", function() api.nvim_set_option_value('textwidth', 10, {}) cursors({ 'aaa', 'bbb' }, 'Qj') feed('Awww xxx yyy zzz') local lines = get_lines() -- The text of both cursors wrapped the same way (same number of lines each). eq(0, #lines % 2) local half = #lines / 2 for i = 1, half do local a = lines[i]:gsub('^aaa', '') local b = lines[half + i]:gsub('^bbb', '') eq(a, b) end -- The primary's line actually wrapped. eq(true, #lines > 2) end) end) describe('undo', function() it('redo (CTRL-R) restores the cursors to their post-edit positions', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQj$') feed('IX ') eq({ 'X aaa', 'X bbb', 'X ccc' }, get_lines()) eq({ { 0, 1 }, { 1, 1 } }, anchors()) feed('u') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) eq({ { 0, 0 }, { 1, 0 } }, anchors()) feed('') eq({ 'X aaa', 'X bbb', 'X ccc' }, get_lines()) -- Splice adjustment alone would drift the marks to col 2: the -- explicit post-session positions must be restored. eq({ { 0, 1 }, { 1, 1 } }, anchors()) -- The primary restores to its recorded post-edit position too (the undo header's cursor is -- the PRE-change position, it serves undo), in sync with the mcursors. eq({ 3, 1 }, api.nvim_win_get_cursor(0)) feed('x') eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) end) it('uu undoes two cascaded edits step by step', function() cursors({ 'abc', 'def' }, 'Q') feed('jx') eq({ 'bc', 'ef' }, get_lines()) feed('x') eq({ 'c', 'f' }, get_lines()) feed('u') eq({ 'bc', 'ef' }, get_lines()) feed('u') eq({ 'abc', 'def' }, get_lines()) feed('') eq({ 'c', 'f' }, get_lines()) -- Counted undo (2u) likewise treats each cascade as one change. feed('2u') eq({ 'abc', 'def' }, get_lines()) end) it('bulk undo across a live insert then a cascaded delete (one step each)', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQj0') feed('IX') -- live insert at all cursors eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) feed('x') -- cascaded delete at all cursors eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) feed('u') -- revert the delete everywhere eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) feed('u') -- revert the insert everywhere eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) eq(3, fn.line('.')) -- primary placement after undoing a live insert end) it('a mapped undo/redo (vim-repeat "nmap u") does not cascade', function() -- vim-repeat maps u/U/ to undo/redo wrappers. Such a mapping changes the buffer, but an -- undo/redo is buffer-global, not a per-cursor edit: it must NOT cascade, or every cursor -- would undo again, over-undoing the whole session (the reported bug). fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('gg0Qj0Qj0') -- 3 cursors feed('x') -- one cascade: aa,bb,cc eq({ 'aa', 'bb', 'cc' }, get_lines()) command('nnoremap u :undo') command('nnoremap :redo') feed('u') -- ONE undo of the cascade, not one-per-cursor (would reach the empty buffer) eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) feed('') -- redo mapping likewise does not cascade eq({ 'aa', 'bb', 'cc' }, get_lines()) -- The guard survives a nested normal_execute() after the undo, in the same mapped command. command('nnoremap u :undo normal! l') feed('u') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) end) it('u/CTRL-R ping-pong toggles without drift', function() -- Repeated undo/redo of one cascade must stabilize (no extmark drift). cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') feed('jx') eq({ 'aa', 'bb', 'cc' }, get_lines()) feed('u') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) feed('') eq({ 'aa', 'bb', 'cc' }, get_lines()) feed('u') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) feed('') eq({ 'aa', 'bb', 'cc' }, get_lines()) end) it('undo steps across the mc-enter boundary', function() -- Q is placement, not an edit: the undo tree spans pre-mc and in-mc -- edits with no extra step in between. fn.setline(1, { 'xxx', 'yyy' }) feed('gg0x') eq({ 'xx', 'yyy' }, get_lines()) feed('Q') feed('jx') eq({ 'x', 'yy' }, get_lines()) feed('u') eq({ 'xx', 'yyy' }, get_lines()) feed('u') eq({ 'xxx', 'yyy' }, get_lines()) end) it('u after a line-inserting cascade restores every cursor position', function() -- "o" shifts the other cursors' lines; undo must move them all back -- (cursor extmarks restore on undo). cursors({ 'aaa', 'bbb', 'ccc' }) feed('oX') eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) feed('u') eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) feed('x') eq({ 'aa', 'bb', 'cc' }, get_lines()) end) it('one u/CTRL-R reverts a whole macro (@q) cascade', function() -- "@x" cascades as ONE unit, so it is one undo block. fn.setreg('q', 'iX\027') cursors({ 'aaa', 'bbb' }, 'Qj') feed('@q') eq({ 'Xaaa', 'Xbbb' }, get_lines()) feed('u') eq({ 'aaa', 'bbb' }, get_lines()) feed('') eq({ 'Xaaa', 'Xbbb' }, get_lines()) end) it('u undoes a visual-mode cascade at all cursors', function() cursors({ 'abc def', 'ghi jkl' }, 'Qj') feed('viwd') eq({ ' def', ' jkl' }, get_lines()) feed('u') eq({ 'abc def', 'ghi jkl' }, get_lines()) -- The cursors are back at their pre-edit positions. feed('x') eq({ 'bc def', 'hi jkl' }, get_lines()) end) it('g-/g+ (time-travel) exits multicursor mode', function() -- Out of scope forever (mcursor.md): time-travel jumps across cascade -- boundaries, where per-cursor state is meaningless: all cursors are -- removed instead. cursors({ 'aaa', 'bbb' }, 'Q') feed('jx') eq({ 'aa', 'bb' }, get_lines()) eq(1, ncursors()) feed('g-') eq(0, ncursors()) eq({ 'aaa', 'bbb' }, get_lines()) -- one step back: the whole cascade feed('g+') eq(0, ncursors()) -- still out of multicursor mode eq({ 'aa', 'bb' }, get_lines()) feed('Q') -- a new session starts cleanly eq(1, ncursors()) end) it('u restores the buffer but NOT the registers (Vim parity)', function() -- Undo never restores registers. cursors({ 'foo x', 'bar y' }, 'Qj0') feed('diw') -- each cursor deletes its word into its own register eq({ ' x', ' y' }, get_lines()) feed('u') eq({ 'foo x', 'bar y' }, get_lines()) -- The primary's register still holds its deletion... eq('bar', fn.getreg('"')) -- ...and the per-cursor values persist too: exiting concatenates them -- (yank DWIM) as if the undo never happened. clear_cursors() eq('foo\nbar\n', fn.getreg('"')) end) end) describe('same-line cursors', function() it('two cursors on one line edit at their own columns', function() cursors({ 'abcdef' }, 'Q4l') feed('x') eq({ 'bcdf' }, get_lines()) feed('x') eq({ 'cd' }, get_lines()) end) it('linewise op with two cursors on one line applies once', function() pending('policy: dedup linewise ops for same-line cursors (mcursor.md)') end) it('coincident cursors merge', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') feed('q=') feed('G') -- all cursors land on the last line feed('q=') feed('x') -- one deletion, not three eq({ 'aaa', 'bbb', 'cc' }, get_lines()) end) end) describe('multibyte', function() it('cursor highlight covers a double-width char', function() local screen = Screen.new(20, 4) cursors({ '日本語', '中文字' }, 'Qj') screen:expect([[ {17:日}本語 | ^中文字 | {1:~ }| | ]]) end) end) describe('terminal multiple-cursors protocol', function() local exec_lua = n.exec_lua before_each(function() -- Stub the UI channel: record emitted sequences instead of sending. exec_lua([[ _G.sent = {} vim.api.nvim_ui_send = function(s) table.insert(_G.sent, s) end ]]) end) --- Runs detect() and replies to its support query with the given TermResponse `sequence`. local function detect(sequence) exec_lua(([[ require('vim._core.mcursor').detect({ chan = 1 }) vim.api.nvim_exec_autocmds('TermResponse', { data = { sequence = %q, chan = 1 }, }) ]]):format(sequence)) end it('enabled only if the terminal reply lists shape 29', function() detect('\027[>1;2;3 q') -- no shape 29 -- The support query was sent, but no cursor updates follow. eq({ '\027[> q' }, exec_lua('return _G.sent')) feed('Q') exec_lua('vim.wait(10)') eq({ '\027[> q' }, exec_lua('return _G.sent')) end) it('displays multicursors as terminal cursors', function() local screen = Screen.new(30, 6) detect('\027[>1;2;3;29;30;40;100;101 q') cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') exec_lua('vim.wait(10)') -- drain the scheduled refresh -- Clear-all, then shape 29 ("follow main cursor") at each position. local sent = exec_lua('return _G.sent') eq('\027[>0;4 q\027[>29;2:1:1;2:2:1 q', sent[#sent]) -- The cell-highlight fallback is suppressed (no {17:} on line 1). screen:expect([[ aaa | ^bbb | ccc | {1:~ }|*2 | ]]) -- Removing all cursors clears the terminal cursors. clear_cursors() exec_lua('vim.wait(10)') sent = exec_lua('return _G.sent') eq('\027[>0;4 q', sent[#sent]) -- tty_cursors(false) restores the cell-highlight fallback. feed('Q') exec_lua("require('vim._core.mcursor').tty_cursors(false)") screen:expect([[ aaa | {17:^b}bb | ccc | {1:~ }|*2 | ]]) end) it('displays the cursors of every visible window (splits)', function() local _ = Screen.new(30, 9) detect('\027[>1;2;3;29;30;40;100;101 q') fn.setline(1, { 'aaa', 'bbb' }) feed('gg0Q') -- cursor in buffer 1 command('split | enew') -- top window: a second buffer fn.setline(1, { 'xxx', 'yyy' }) feed('gg0Q') -- cursor in buffer 2 exec_lua('vim.wait(10)') -- The last sequence draws BOTH cursors: buffer 2's in the focused top -- window, and buffer 1's in the (unfocused) bottom window. local sent = exec_lua('return _G.sent') ---@type string[] local seq = sent[#sent] local win1 = fn.win_getid(fn.winnr('j')) local pos1 = fn.screenpos(win1, 1, 1) t.ok( seq:find(('2:%d:%d'):format(pos1.row, pos1.col), 1, true) ~= nil, 'buf1 cursor drawn', seq ) local pos2 = fn.screenpos(0, 1, 1) t.ok( seq:find(('2:%d:%d'):format(pos2.row, pos2.col), 1, true) ~= nil, 'buf2 cursor drawn', seq ) end) end) describe('operatorfunc (g@)', function() it('VISUAL-mode surround ("S") cascades', function() pending('visual ":" LHS-replay: the visual selection differs per cursor; TODO') -- Minimal vim-surround "S": VSurround is an Ex command plus a getchar() -- payload: `:call opfunc(visualmode(),...)` + the wrap char. n.exec([=[ function! VisualSurround() abort let c = nr2char(getchar()) let [l1, c1] = getpos("'<")[1:2] let [l2, c2] = getpos("'>")[1:2] call setpos('.', [0, l2, c2, 0]) exe "normal! a" . c call setpos('.', [0, l1, c1, 0]) exe "normal! i" . c endfunction xnoremap S :call VisualSurround() ]=]) cursors({ 'foo one', 'bar two' }, 'Qj0') feed('viwS"') -- Each cursor's own word is wrapped (per-cursor extents, like "viwd"). eq({ '"foo" one', '"bar" two' }, get_lines()) end) it( 'surround-style plugin cascades, getchar() payload included; one u/CTRL-R reverts', function() -- The op edits each cursor's region through :normal + register juggling (a full -- exec_normal() per cursor). n.exec(t_atom.minisurround_vim) cursors({ 'alpha beta', 'gamma delta', 'epsilon zeta' }, 'Qj0Qj0') atoms_start() feed('ysiw"') -- The atom is the redobuff plus the getchar()'d payload: the replayed -- opfunc reads the same wrap char. eq({ 'g@iw"' }, atoms_tail(1)) eq({ '"alpha" beta', '"gamma" delta', '"epsilon" zeta' }, get_lines()) -- The whole cascade (primary + replays) is ONE undo step: a single -- u/CTRL-R reverts or reapplies it at every cursor. feed('u') eq({ 'alpha beta', 'gamma delta', 'epsilon zeta' }, get_lines()) feed('') eq({ '"alpha" beta', '"gamma" delta', '"epsilon" zeta' }, get_lines()) end ) it('a no-effect operator (aborted "ysa[") does not cascade; cursors survive', function() -- vim-surround "ysa[" whose surround char is /CTRL-C: a redoable g@ whose opfunc does -- nothing. Its "a[" textobject jumps EVERY cursor to the same "[", so a cascade would -- collapse them (dedupe). No edit and no register write = no per-cursor effect: must not -- cascade. n.exec([[ function! Noop(type) abort call getchar() endfunction function! NoopSetup() abort set operatorfunc=Noop return 'g@' endfunction nnoremap ,s NoopSetup() ]]) fn.setline(1, { 'aaa', 'bbb', 'ccc', 'x [y] z' }) -- only line 4 has brackets feed('gg0Qj0') -- cursor on line 1, primary on line 2 (neither has brackets) feed(',sa[z') -- g@ + a[ jumps primary to line 4's "["; opfunc getchar()'s "z", does nothing eq(1, ncursors()) -- the cursor survives end) it('cursors placed inside the opfunc are live for the next typed cascade', function() -- Occurrence-operator pattern (vim-mode-plus "co{motion}", issue #21334): the -- 'operatorfunc' places a cursor at each occurrence of the word within the motion, then -- a following typed edit cascades to all of them. Pins that nvim_mcursor() called from -- WITHIN an opfunc yields cursors the next command cascades to (the g@ itself has no -- effect, so it does not cascade and the placed cursors survive, like |v_Q| placement). n.exec_lua([==[ _G.occur_opfunc = function() local ms = vim.fn.matchbufline('%', _G.occur_pat, vim.fn.line("'["), vim.fn.line("']")) vim.api.nvim_win_set_cursor(0, { ms[1].lnum, ms[1].byteidx }) for i = 2, #ms do vim.api.nvim_mcursor(0, { ms[i].lnum, ms[i].byteidx }) end end vim.keymap.set('n', 'co', function() _G.occur_pat = ([[\<%s\>]]):format(vim.fn.expand('')) -- before g@ moves the cursor vim.o.operatorfunc = 'v:lua.occur_opfunc' return 'g@' end, { expr = true }) ]==]) fn.setline(1, { 'text a text', 'b text c' }) api.nvim_win_set_cursor(0, { 1, 0 }) -- on the first "text" feed('coip') -- place a cursor at every "text" in the paragraph eq(2, ncursors()) -- 3 matches: primary + 2 multicursors feed('ciwWORD') -- a typed edit cascades to the opfunc-placed cursors eq({ 'WORD a WORD', 'b WORD c' }, get_lines()) end) it( 'payload mapping (":call" + getchar, like vim-surround "ds") cascades via LHS-replay', function() -- A mapping whose edit is done through :normal is invisible to atom -- capture (decide-once sees nothing). It cascades by re-running the -- mapping (LHS + the getchar()'d target) at each cursor: LHS-replay. n.exec(t_atom.delsurround_vim) fn.setline(1, { 'a (one)', 'b (two)', 'c (three)' }) feed('gg0f(Qj0f(Qj0f(') atoms_start() feed('ds)') -- ")" is the getchar()'d payload eq({ 'a one', 'b two', 'c three' }, get_lines()) -- The emitted atom carries the resolution plus the getchar()'d payload; the cascade -- itself re-runs `lhs` (the edit is invisible, so nothing was queued for it). local ev = atoms()[#atoms()] eq({ lhs = 'ds)', keys = ':call DelSurround()\n)' }, { lhs = ev.lhs, keys = ev.keys }) -- Same for a mapping that produces NO capturable keys at all (kKeyOpaque): -- "" (K_COMMAND) and a Lua callback (K_LUA). Both are real user -- keystrokes, so their edit is a mapping edit and cascades by LHS-replay. command('nnoremap normal! x') n.exec_lua([[vim.keymap.set('n', '', function() vim.cmd('normal! x') end)]]) for _, lhs in ipairs({ '', '' }) do clear_cursors() fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('gg0QjQj') feed(lhs) eq({ 'aa', 'bb', 'cc' }, get_lines()) end -- Op-pending payload mapping (vim-sneak :omap) cascades by `keys`. n.exec(t_atom.minisneak_vim) clear_cursors() fn.setline(1, { 'aa (x) here', 'bb (y) here', 'cc (z) here' }) feed('gg0QjQj') feed('dzhe') eq({ 'here', 'here', 'here' }, get_lines()) -- Lua :omap textobject (starts Visual mode, |omap-info|) cascades by `keys` too. #41482 n.exec_lua([[ vim.keymap.set('o', 'gt', function() vim.cmd('normal! viw') end) ]]) clear_cursors() fn.setline(1, { 'aaa xxx', 'bbb yyy', 'ccc zzz' }) feed('gg0wQj0wQj0w') feed('dgt') eq({ 'aaa ', 'bbb ', 'ccc ' }, get_lines()) end ) end) describe(']C and [C', function() local function cur() return { fn.line('.'), fn.col('.') - 1 } end -- The default mappings require the standard startup. before_each(function() n.clear({ args_rm = { '--cmd' } }) end) it(']C scrolls the viewport to an off-screen cursor', function() local screen = Screen.new(30, 6) local lines = {} ---@type string[] for i = 1, 40 do lines[i] = ('line %d'):format(i) end fn.setline(1, lines) feed('gg0Q') api.nvim_mcursor(0, { 30, 0 }) feed(']C') -- jump to line 30: the viewport must follow eq(30, fn.line('.')) screen:expect({ any = 'ine 30' }) -- ("l" is under the painted cursor cell) end) it('cycle through the cursors, wrapping', function() cursors({ 'aaa', 'bbb', 'ccc', 'ddd' }, 'Q2jllQ') feed('gg0j') feed(']C') eq({ 3, 2 }, cur()) feed(']C') -- wraps eq({ 1, 0 }, cur()) feed('2]C') -- count eq({ 1, 0 }, cur()) feed('[C') eq({ 3, 2 }, cur()) feed('[C') eq({ 1, 0 }, cur()) end) it('does not move the other cursors in q= mode', function() cursors({ 'aaa', 'bbb', 'ccc' }, 'Qj') feed('q=') feed(']C') eq({ 1, 0 }, cur()) eq({ { 0, 0 } }, anchors()) feed('q=') end) it('beeps and does not move without cursors', function() fn.setline(1, { 'aaa' }) feed('gg0') eq(0, fn.assert_beeps('normal! ]C')) eq({ 1, 0 }, cur()) end) end) describe('treesitter interaction', function() it('markdown highlighting survives a live insert', function() n.exec_lua([[ -- Large, injection-heavy buffer: multi-slice ASYNC parses (the -- crash lived in a resumed parse's external scanner). local lines = {} for i = 1, 800 do vim.list_extend(lines, { ('# Section %d'):format(i), '', '- item with `code` and *emphasis*', ' - nested [link](http://x)', '', '```lua', 'local x = ' .. i, '```', '', }) end vim.api.nvim_buf_set_lines(0, 0, -1, true, lines) vim.treesitter.start(0, 'markdown') ]]) feed('gg0Q2jQ2jQ4j') feed('A') for c in ('hello world'):gmatch('.') do feed(c) n.poke_eventloop() end feed('') feed('u') feed('') n.assert_alive() end) end) describe('clipboard', function() it("perf: provider syncs once per cascade with 'clipboard'", function() n.exec_lua([[ _G.copies = 0 _G.content = {} vim.g.clipboard = { name = 'test', copy = { ['+'] = function(lines) _G.copies = _G.copies + 1 _G.content = lines end, }, paste = { ['+'] = function() return _G.content end, }, } vim.o.clipboard = 'unnamedplus' ]]) cursors({ 'aa bb', 'cc dd', 'ee ff' }) local base = n.exec_lua('return _G.copies') feed('dw') eq({ 'bb', 'dd', 'ff' }, get_lines()) -- One provider sync for the primary's own delete, ONE for the whole -- cascade (not one per cursor), and the primary's registers win. eq(base + 2, n.exec_lua('return _G.copies')) eq({ 'ee ' }, n.exec_lua('return _G.content')) end) end) describe('UI integration', function() it('cursor positions are pushed to UIs (win_extmark)', function() local screen = Screen.new(30, 5) cursors({ 'aaa', 'bbb', 'ccc' }, 'QjlQj') local ns = api.nvim_create_namespace('nvim.multicursor') local marks = api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) eq(2, #marks) screen:expect({ grid = [[ {17:a}aa | b{17:b}b | c^cc | {1:~ }| | ]], extmarks = { [2] = { { 1000, ns, marks[1][1], 0, 0 }, { 1000, ns, marks[2][1], 1, 1 }, }, }, }) end) it('showcmd area shows the cursor count ("N×")', function() local screen = Screen.new(30, 5) command('set showcmd') -- the test env defaults to 'noshowcmd' cursors({ 'aaa', 'bbb', 'ccc' }, 'Q') screen:expect({ any = '1×' }) feed('jQ') screen:expect({ any = '2×' }) command('silent normal! 1q=') -- Follow mode: "=" prefix (silent to avoid the q= message). feed('l') -- Tickle showcmd redraw. screen:expect({ any = '=2×' }) command('silent normal! 2q=') feed('h') -- Tickle showcmd redraw. clear_cursors() -- cleared with the cursors feed('') -- the indicator refreshes on the next command screen:expect([[ aaa | ^bbb | ccc | {1:~ }| | ]]) end) it('showcmd area shows the cursor count ("N×") with ui2', function() local screen = Screen.new(30, 5) command('set showcmd') n.exec_lua([[require('vim._core.ui2').enable({})]]) cursors({ 'aaa', 'bbb', 'ccc' }, 'Q') screen:expect({ any = '1×' }) feed('jQ') screen:expect({ any = '2×' }) command('silent normal! 1q=') -- Follow mode: "=" prefix (silent to avoid the q= message). feed('l') -- Tickle showcmd redraw. screen:expect({ any = '=2×' }) end) end) describe('workflows', function() it('split visual selection into line cursors', function() -- {Visual}Q fn.setline(1, { 'aaaa', 'bbbb', 'cc', 'dddd' }) feed('gg0ll') feed('V2j') feed('Q') -- Primary cursor is the top of the range. eq({ 1, 2 }, api.nvim_win_get_cursor(0)) -- One cursor per selected line, at primary cursor's column (on the short line: past EOL). feed('iX') eq({ 'aaXaa', 'bbXbb', 'ccX', 'dddd' }, get_lines()) -- The mapping enabled follow-motion (q=). feed('jx') eq({ 'aaXaa', 'bbbb', 'cc', 'ddd' }, get_lines()) end) it('place a cursor at a range of quickfix items: :cdo normal! Q', function() fn.setline(1, { 'aaa', 'bbb', 'ccc', 'ddd' }) fn.setqflist({ { bufnr = fn.bufnr(''), lnum = 1, col = 1 }, { bufnr = fn.bufnr(''), lnum = 2, col = 1 }, { bufnr = fn.bufnr(''), lnum = 4, col = 1 }, }) command('cdo normal! Q') eq(3, ncursors()) feed('3G0') -- Move the primary off the last item's cursor (it would double-apply). feed('x') eq({ 'aa', 'bb', 'cc', 'dd' }, get_lines()) -- A range addresses quickfix items: cursors on items 2-3 only. clear_cursors() command('2,3cdo normal! Q') eq(2, ncursors()) end) it('CTRL-X/CTRL-A apply at each cursor; g CTRL-A is the counter', function() cursors({ 'x = 5', 'y = 5' }, 'Qj') feed('') eq({ 'x = 4', 'y = 4' }, get_lines()) feed('') eq({ 'x = 5', 'y = 5' }, get_lines()) -- g_CTRL-A inserts counter at the cursor positions. feed('g') eq({ 'x = 15', 'y = 25' }, get_lines()) end) it('o with primary cursor BETWEEN the other cursors', function() -- The primary's own line-insert shifts the other cursors' extmarks BEFORE the replays. fn.setline(1, { 'aaa', 'bbb', 'ccc' }) api.nvim_mcursor(0, { 1, 0 }) api.nvim_mcursor(0, { 3, 0 }) api.nvim_win_set_cursor(0, { 2, 0 }) feed('oX') eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) end) it('typeahead behind the cascade-triggering key is not consumed by replays', function() -- Batch input: "x" cascades; queued "yy" must survive the replays (save_current_state), then -- cascade (per-cursor registers prove the yank ran at both cursors). cursors({ 'abc', 'def' }, 'Qj') feed('xyy') eq({ 'bc', 'ef' }, get_lines()) feed('p') eq({ 'bc', 'bc', 'ef', 'ef' }, get_lines()) end) it('x then p at EOL on lines of different lengths', function() -- Jagged EOL: each cursor deletes last char into its own register, then pastes at its own -- (shorter) EOL. fn.setline(1, { 'abc', 'de' }) feed('gg$Q') feed('j$') feed('x') eq({ 'ab', 'd' }, get_lines()) feed('p') eq({ 'abc', 'de' }, get_lines()) end) it('fold parity: cascade at a cursor inside a closed fold', function() -- Operator on a closed fold applies to the whole fold (|fold-behavior|), so the replayed "x" -- deletes the fold's lines. fn.setline(1, { 'aaa', 'bbb', 'ccc', 'ddd' }) feed('3G0Q') command('2,4fold') eq(2, fn.foldclosed(3)) feed('gg0') feed('x') eq({ 'aa' }, get_lines()) end) it('type=excmd CmdAtoms are emit-only, ":s" does not cascade', function() -- cmdline payloads are captured but never cascaded: only the primary's ":s" runs. cursors({ 'foo', 'foo' }, 'Qj') atoms_start() feed(':s/o/O/') eq({ 'foo', 'fOo' }, get_lines()) local ev = atoms()[#atoms()] eq('excmd', ev.type) eq('s/o/O/', ev.text) end) end) describe('@ (macro replay)', function() it('cascades at each cursor', function() fn.setline(1, { 'aaa', 'bbb', 'ccc' }) feed('gg0qqxq') -- record "x"; line 1 becomes "aa" feed('jQ') feed('j0') feed('@q') eq({ 'aa', 'bb', 'cc' }, get_lines()) -- "@@" repeats, still cascading. feed('@@') eq({ 'aa', 'b', 'c' }, get_lines()) -- A macro with an insert session cascades too. clear_cursors() api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb', 'ccc' }) feed('gg0qwA!q') -- record "A!"; line 1 becomes "aaa!" feed('jQ') feed('j0') feed('@w') eq({ 'aaa!', 'bbb!', 'ccc!' }, get_lines()) -- A count applies at each cursor. clear_cursors() api.nvim_buf_set_lines(0, 0, -1, true, { 'aaaa', 'bbbb', 'cccc' }) feed('gg0') feed('jQ') feed('j0') feed('2@q') -- the "x" macro, twice per cursor eq({ 'aaaa', 'bb', 'cc' }, get_lines()) end) it('from a cursorless buffer, cascades in the buffer it enters', function() -- Capture starts before the macro runs (buffer unknown). A macro typed where there are no -- cursors, cascades in the buffer it navigates into, like a mapping. command('set hidden') cursors({ 'aaa', 'bbb' }, 'jQk') -- buf1: primary on line 1, cursor on line 2. command('vsplit | enew') -- buf2: no cursors. fn.setreg('q', k('px')) feed('@q') eq({ 'aa', 'bb' }, get_lines()) end) end) describe('CmdAtom', function() it('fires for a cascaded operation', function() cursors({ 'aaa', 'bbb' }, 'Q') atoms_start() feed('jx') -- Atom shape: see cmdatom_spec. With cursors: no extra atoms from the cascade replays. eq({ 'j', 'dl' }, atoms_tail(2)) -- A yank cascades (per-cursor registers) but does not edit. feed('yy') eq(false, atoms()[#atoms()].changed) end) it('non-edit operator (zfap) cascades; fold toggles (za) do not', function() fn.setline(1, { 'aa', 'aa', '', 'bb', 'bb', '' }) feed('4G0Q') feed('gg0') atoms_start() feed('zfap') -- With cursors: the cascade adds no atoms. (Atom shape is covered in cmdatom_spec.) eq({ 'zfap' }, atoms_tail(1)) -- The fold operator cascades: each cursor folds its own paragraph. eq({ { 1, 3 }, { 4, 6 } }, { { fn.foldclosed(1), fn.foldclosedend(1) }, { fn.foldclosed(4), fn.foldclosedend(4) }, }) feed('za') eq({ 'za' }, atoms_tail(1)) -- Fold toggles are view state: emitted, not cascaded. eq({ -1, 4 }, { fn.foldclosed(1), fn.foldclosed(4) }) end) it('records q= motions', function() cursors({ 'abcd', 'efgh' }, 'Qj') atoms_start() feed('q=') feed('l') feed('l') feed('q=') eq({ 'q=', 'l', 'l', 'q=' }, atoms_tail(4)) eq('motion', atoms()[#atoms() - 1].type) end) end) describe('programmatic edits', function() it('API edits do not cascade; they shift the cursors, which then track', function() -- Extmarks are authoritative: the cascade re-queries positions, so an interleaved API edit -- just shifts where the replays land. cursors({ 'aaa', 'bbb', 'ccc' }) n.exec_lua("vim.api.nvim_buf_set_lines(0, 0, 0, true, { 'zzz' })") eq({ 'zzz', 'aaa', 'bbb', 'ccc' }, get_lines()) feed('x') eq({ 'zzz', 'aa', 'bb', 'cc' }, get_lines()) end) it('TextChanged autocmd editing the buffer does not re-cascade', function() fn.setline(1, { 'top', 'aaa', 'bbb' }) feed('2G0Q') feed('j') n.exec_lua([[ _G.fired = 0 vim.api.nvim_create_autocmd('TextChanged', { callback = function() _G.fired = _G.fired + 1 if _G.fired == 1 then vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { 'X' }) end end, }) ]]) feed('x') n.poke_eventloop() eq({ 'Xtop', 'aa', 'bb' }, get_lines()) -- Once for the whole cascade + once for the autocmd's own API edit. -- Not once per cursor, and the autocmd edit did not cascade. eq(2, n.exec_lua('return _G.fired')) end) it('replacing the whole buffer via API does not crash the cascade', function() cursors({ 'aaa', 'bbb', 'ccc' }) n.exec_lua("vim.api.nvim_buf_set_lines(0, 0, -1, true, { 'fresh', 'stuff' })") -- The cursor lines are gone: both marks collapse to the edit boundary (past the last line), -- where they merge/dedupe into one cursor; its replay clamps onto the last line. feed('x') eq({ 'fresh', 'uff' }, get_lines()) eq(0, ncursors()) end) it(':normal! never cascades (programmatic input)', function() cursors({ 'aaa', 'bbb' }, 'Qj') command('normal! x') -- Programmatic, no cascade (primary only). eq({ 'aaa', 'bb' }, get_lines()) feed('x') -- User input, cascades. eq({ 'aa', 'b' }, get_lines()) end) end) end)