From f6bf814378c6e51aeecb8333040a78666b543392 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 18 Aug 2026 07:50:27 -0400 Subject: [PATCH] fixfix(cmdatom): repeat Visual ; CmdFrame + stage #41355 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - Dot-repeat of a Visual selection prepared by a `` mapping, results in E1255 and leaves Visual mode active. - Visual-mode capture is implemented as a second, parallel "capture engine": it re-composes a CmdSpec from cmdarg_T per key and decides replayability from its own table of "void" key classes. Solution: - Model nested `normal_execute()` as a `CmdFrame` stack, instead of a single module-scoped `stage`. - Capture `` ":norm …" commands as subatoms from its nested frames. - Produce every command exactly once; atom_push_raw() routes the atom to the Visual composite while a selection is open, like it already does for mapping composites. - `v/patd` is now repeatable and cascades. --- runtime/doc/autocmd.txt | 4 +- src/nvim/input_cmdatom.c | 210 +++++++++--------- src/nvim/input_cmdatom.h | 12 +- src/nvim/normal.c | 6 +- test/functional/editor/atom_testutil.lua | 8 +- test/functional/editor/cmdatom_spec.lua | 56 ++++- .../legacy/094_visual_mode_operators_spec.lua | 2 +- test/old/testdir/test_visual.vim | 2 +- 8 files changed, 175 insertions(+), 125 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 326ce1a118..42c28cdc4b 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -420,10 +420,10 @@ ChanOpen Just after a channel was opened. info as from |nvim_get_chan_info()| *CmdAtom* -CmdAtom After a user action (an input "atom"): any +CmdAtom After a user action (an "atom" of input): any motion, operator, insert session, Visual-mode keysequence, Ex cmdline (":cnext"), - mapping (and its sub-atoms), or scroll/mouse. + mapping (and its subatoms), or scroll/mouse. Only for user input, not programmatic input: INPUT ATOM ~ diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 3743dc4002..9bda298249 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -6,8 +6,7 @@ // // Every "user action" is an atom (emits CmdAtom event), but not every atom is "replayable". // - Replayable (cascade, dot-repeat) requires the full command grammar. -// - No atoms for: mouse drag/release (TODO(justinmk)?), terminal-mode input, aborted operations, -// command fragments (counts, register prefixes). +// - No atoms for: mouse drag/release (TODO(justinmk)?), terminal-mode input, aborted operations. #include #include @@ -41,9 +40,19 @@ #include "input_cmdatom.c.generated.h" +static bool mc_replaying(void) +{ + return false; +} + +static void mc_vsel_refresh(void) +{ +} + CmdAtomVec g_atoms = KV_INITIAL_VALUE; -/// Total atoms ever pushed: so atom_cmd_start() can detect if a cmd already pushed its own atom. -static uint64_t atom_pushes = 0; +/// Capture clock: ticks on any kind of capture (atom push, Visual subatom). Used to answer "was +/// anything captured during this command (including its nested frames)?". +static uint64_t atom_captures = 0; /// Suppresses atom pushes. static bool atom_suppressed = false; /// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions. @@ -59,17 +68,14 @@ static struct { varnumber_T tick; ///< b:changedtick at start. } composite; -/// Staged atom, will be pushed at command end. -static struct { - CmdAtom atom; ///< One is staged when `keys` is non-NULL. - varnumber_T tick; ///< b:changedtick when the atom was staged -} stage; +/// The executing command's frame; its `parent` chain spans nested `normal_execute()`. +static CmdFrame *cur_frame = NULL; /// State of a Visual composite atom. typedef enum { // Nothing to replay: - kVatomNone, ///< No pending visual atom. - kVatomVoid, ///< Visual keyseq was tainted/poisoned (by mouse, gv, …), not replayable. + kVatomNone, ///< No pending Visual atom. + kVatomVoid, ///< Not replayable: tainted/poisoned (by mouse, gv, …). But may emit CmdAtom. // Accumulating, replayable: kVatomTyped, ///< User input (typed, or mapping/macro): emitted/cascaded at end. @@ -138,7 +144,10 @@ void atom_free_all(void) { atoms_free(&g_atoms); kv_destroy(g_atoms); - atom_stage_drop(); + // A mid-command exit (e.g. ":qa!" from an option-expr) leaves live frames with staged atoms. + for (CmdFrame *frame = cur_frame; frame != NULL; frame = frame->parent) { + atom_free(&frame->staged); + } atom_composite_abort(); kv_destroy(composite.atoms); XFREE_CLEAR(curcmd.cmdline); @@ -330,7 +339,16 @@ static void atom_emit(const CmdAtom *atom, const char *pending, bool cascade) void atom_push_raw(bool cascade, CmdAtom atom) { assert(atom.keys != NULL); - atom_pushes++; + if (Visual.active && atom_visual_replayable()) { + // Collecting the Visual composite: subatom of the pending visual atom. + if (vatom.state == kVatomTyped) { + // Not for kVatomFed: redo-prep must not mark the enclosing span as captured. + atom_captures++; + } + kv_push(vatom.atoms, atom); + return; + } + atom_captures++; if (atom.type == kAVisual && kv_size(atom.atoms) > 0) { // The completing operator is the only subatom that could have edited. CmdAtom *last = &kv_A(atom.atoms, kv_size(atom.atoms) - 1); @@ -364,42 +382,36 @@ static void atom_push(bool cascade, CmdAtom atom) } /// Stages an atom built before its command executes (do_pending_operator() prep-exempt, Visual -/// ops); will be pushed at command end, once `changed` is known. +/// ops), in the command's frame; pushed at frame end, once `changed` is known. static void atom_stage_set(CmdAtom atom) { - assert(!atom_staged()); // If this happens, the stage may need to become a stack... - atom_stage_drop(); + assert(cur_frame != NULL); + assert(!atom_staged()); // One stage per frame: a second would discard a captured command. + atom_free(&cur_frame->staged); if (atom_blocked()) { // Now, not at flush: drop the atom of an internal operator (atom_suppress()). atom_free(&atom); return; } assert(atom.keys != NULL); - stage.atom = atom; - stage.tick = buf_get_changedtick(curbuf); + cur_frame->staged = atom; } -/// True if an atom is staged for the current command. +/// True if an atom is staged for the current command (frame). static bool atom_staged(void) { - return stage.atom.keys != NULL; + return cur_frame != NULL && cur_frame->staged.keys != NULL; } -/// Discards the staged atom. -static void atom_stage_drop(void) +/// Pushes the frame's staged atom (no-op if none). +static void atom_stage_flush(CmdFrame *frame) { - atom_free(&stage.atom); // `keys=NULL` means "nothing staged". -} - -/// Pushes the staged atom (no-op if none). -static void atom_stage_flush(void) -{ - if (!atom_staged()) { + if (frame->staged.keys == NULL) { return; } - stage.atom.changed = buf_get_changedtick(curbuf) != stage.tick; - atom_push(true, stage.atom); // Staged commands are always edits (cascadable). - stage.atom = (CmdAtom){ 0 }; + frame->staged.changed = buf_get_changedtick(curbuf) != frame->tick; + atom_push(true, frame->staged); // Staged commands are always edits (cascadable). + frame->staged = (CmdAtom){ 0 }; } /// Queues an LHS-replay atom: a mapping that edited invisibly (:normal/:call, "ds'") re-runs @@ -498,10 +510,10 @@ void atom_suppress(bool suppress) atom_suppressed = suppress; } -/// Block atom pushes if: cascade in-progress, internal op is executing, or vatom is accumulating. +/// Block atom pushes if: cascade in-progress, internal op is executing, or vatom is voided. static bool atom_blocked(void) { - return atom_suppressed || (vatom.state != kVatomNone && Visual.active); + return mc_replaying() || atom_suppressed || (vatom.state == kVatomVoid && Visual.active); } /// Decides if the command is capturable. @@ -727,42 +739,6 @@ String atom_visual_span(void) return atoms_concat_keys(vatom.atoms); } -/// Captures a typed Visual-mode command into the pending visual atom (vatom): one subatom of -/// the accumulating "viwee"-style keysequence. -static void atom_capture_visual(cmdarg_T *ca, const CmdBaseline *old) -{ - if (!atom_visual_replayable()) { - return; - } - unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar); - if (Visual.select || ca->cmdchar >= 0x100 - || (keycls & (kKeyPayload | kKeyScrollMove)) != 0 - || (ca->cmdchar == 'g' && ca->nchar == 'v')) { - // Not replayable: mouse/special keys, motions with an interactively-typed payload, Select mode, - // "gv" (an absolute region), scrolling that moves the cursor (viewport-dependent extents). - vatom.state = kVatomVoid; - return; - } - if ((keycls & kKeyScrollView) != 0) { - // A viewport scroll (C-E/C-Y) does not change the selection, UNLESS it dragged the cursor along - // (viewport edge, 'scrolloff'), which moved the selection end. - if (!equalpos(old->pos, curwin->w_cursor)) { - vatom.state = kVatomVoid; - } - return; - } - if (ca->cmdchar == 'Q' || ca->cmdchar == 'q' || ca->cmdchar == '"') { - // Skip: recording/replay commands are meta (not part of the edit); a register spec ('"x') is - // re-added by the operator that ends the selection (redo_prefix()). - return; - } - bool operand = nv_nchar_is_arg(ca->cmdchar); - // Omit `regname`, it would prefix '"x' to every command captured after a register spec. - CmdSpec spec = { .count = ca->count0, .cmd = ca->cmdchar, - .cmd2 = operand ? NUL : ca->nchar, .arg = operand ? ca->nchar : NUL }; - kv_push(vatom.atoms, ((CmdAtom){ .type = kAMotion, .spec = spec, .keys = atom_redo_keys(spec) })); -} - /// Ends the pending visual atom, appends `suffix`, and stages it. Or discards it if selection is /// unreplayable (void/absent). /// @@ -1005,37 +981,37 @@ static void atom_ins_push(const InsSession *session, bool cascade) } /// Samples the pre-command state at normal_execute() entry; atom_cmd_end() diffs against it to -/// classify the command (motion, Visual-mode transition, edit). -void atom_cmd_start(CmdBaseline *old) +/// classify the command (motion, Visual-mode transition, edit). Pushes the frame (`cur_frame`). +void atom_cmd_start(CmdFrame *old) { old->pos = curwin->w_cursor; old->buf = curbuf; old->tick = buf_get_changedtick(curbuf); old->visual = Visual; old->keytyped = KeyTyped; - old->pushes = atom_pushes; + old->captures = atom_captures; // Sampled: "q=" toggled DURING a command must not apply to it retroactively. old->follow = false; old->consumers = atom_buf_has_consumers(); // Diffed at command end: detects a register-write (yank). old->reg_ts = old->consumers ? reg_max_ts(true) : 0; - old->staged = atom_staged(); + old->staged = (CmdAtom){ 0 }; + old->parent = cur_frame; + cur_frame = old; curcmd.op_global = false; atom_redo_reset(); } -/// Captures the typed command's atom: in Visual mode into `vatom`, else one atom per command. +/// Captures the typed command's atom: one atom per command, produced from the CmdFrame diff and +/// routed by atom_push_raw() (emit, or collect as a mapping/Visual subatom). /// /// Skipped for a command that stuffed keys ("x" stuffs "dl": its resolution is the atom), or that /// already captured its own atom (do_pending_operator(), insert spans). -static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel) +static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) { - // Not atom_blocked(): Visual capture must run while the vatom accumulates. - if (atom_suppressed) { + if (mc_replaying() || atom_suppressed) { return; } - // Non-user input (":normal", scripted macro) pushes no atoms, but still accumulates the vatom, - // so its operator can prep a redo: ":normal! vjd" is dot-repeatable. const bool user = atom_is_user_cmd(); const unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar); // Opaque cmd that changed nothing is invisible; one that changed the buffer/selection voids the @@ -1068,13 +1044,25 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel && buf_get_changedtick(curbuf) != old->tick) || ins_cascaded)) { map_edit = true; } + bool vis = false; if (Visual.active) { if (!old->visual.active) { atom_visual_reset(); // Decided once, at session start. vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed; } - atom_capture_visual(ca, old); + // Collecting the Visual composite: gated on the session (not atom_capturable()), so fed + // selections (":normal! vjd") still accumulate for redo-prep. + vis = atom_visual_replayable(); + if (vis && (Visual.select || (ca->cmdchar == 'g' && ca->nchar == 'v'))) { + // Not replayable: Select-mode input; "gv" (absolute region). + vatom.state = kVatomVoid; + vis = false; + } + if (vis && (ca->cmdchar == 'Q' || ca->cmdchar == 'q')) { + // Recording/replay commands are meta (not part of the edit): skip, no void. + vis = false; + } } else if (old->visual.active) { if (user && old->follow && atom_visual_replayable() && kv_size(vatom.atoms) > 0) { // Follow-motion ("q="): a selection abandoned without an operator (, "v" toggle) still @@ -1084,10 +1072,17 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel } else { atom_visual_reset(); } - } else if (atom_capturable(old->consumers, old->keytyped) - && atom_pushes == old->pushes && !atom_staged() - && ca->oap->op_type == OP_NOP - && stuff_empty() && !ins_cascaded) { + } + if ((vis && atom_captures == old->captures && ca->oap->op_type == OP_NOP) + || (!Visual.active + && !old->visual.active + && atom_capturable(old->consumers, old->keytyped) + && atom_captures == old->captures + && !atom_staged() + && ca->oap->op_type == OP_NOP + && stuff_empty() + && !ins_cascaded)) { + const size_t collected = kv_size(vatom.atoms); // KeyTyped survives stuffing but not macro playback; mapping/macro-fed commands are covered by // atom_composite_active(). bool special_motion = (keycls & kKeyMotion) != 0; @@ -1131,23 +1126,29 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel } else { atom_free(&atom); } - } else if (ca->searchbuf != NULL && (ca->cmdchar == '/' || ca->cmdchar == '?')) { - // Payload typed in the cmdline ("/pat"). Emit-only. + } else if (ca->searchbuf != NULL && (ca->cmdchar == '/' || ca->cmdchar == '?') + && !(vis && unchanged)) { + // Payload typed in the cmdline ("/pat"). Emit-only. Not if pattern was not found. CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf); atom.changed = changed; atom_push(false, atom); - } else if (curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) { - // Same for ":cnext" or "cnext". + } else if (!vis && curcmd.cmdline != NULL + && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) { + // Same for ":cnext" or "cnext". Never a Visual subatom. CmdAtom atom = atom_from_cmdline(kAEx, ca, curcmd.cmdline); atom.changed = changed; atom_push(false, atom); - } else if (replayable) { + } else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) { // Non-redoable command (u, zz, q=): never cascaded as an edit. - CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kACommand, - atom_cmd_spec(ca)); + CmdSpec spec = atom_cmd_spec(ca); + if (vis) { + // Omit `regname`: would prefix '"x' to every command collected; op-end re-adds it later. + spec.regname = 0; + } + CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kACommand, spec); atom.changed = changed; atom_push(follow, atom); - } else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) { + } else if (!vis && (scroll_cmd || mouse_cmd) && !atom_composite_active()) { // Emit-only (viewport-dependent), and never a subatom. Composite keys must stay replayable. CmdSpec spec = atom_cmd_spec(ca); if (IS_SPECIAL(ca->cmdchar)) { @@ -1158,18 +1159,26 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel atom.changed = changed; atom_push(false, atom); } + if (vis && kv_size(vatom.atoms) == collected && !unchanged) { + // Not replayable: moved the selection by non-collectible keys. + vatom.state = kVatomVoid; + } + } + if (vis && (curbuf != old->buf || buf_get_changedtick(curbuf) != old->tick)) { + // Not replayable: edited buffer during selection, so the keys do not describe the change. + vatom.state = kVatomVoid; + } + if (Visual.active && user && toplevel) { + mc_vsel_refresh(); } } -/// Completes a cmd at normal_execute() exit: captures its atom, pushes the staged one, ends the -/// composite. -void atom_cmd_end(cmdarg_T *ca, const CmdBaseline *old, bool toplevel) +/// Completes a cmd at normal_execute() exit: captures its atom, pushes its staged one, ends the +/// composite. Pops the frame. +void atom_cmd_end(cmdarg_T *ca, CmdFrame *old, bool toplevel) { atom_capture_cmd(ca, old, toplevel); - if (!old->staged) { - // Flush only what this cmd staged. In case of nested :norm (e.g. 'indentexpr' during "gq"). - atom_stage_flush(); - } + atom_stage_flush(old); // The clock edge. Only at toplevel: cascading from a nested normal_execute() would recurse. // Deferred while a mapping executes (its keys are still in typebuf), so its commands collapse as @@ -1178,4 +1187,5 @@ void atom_cmd_end(cmdarg_T *ca, const CmdBaseline *old, bool toplevel) map_edit = false; atom_composite_end(ca->oap->op_type != OP_NOP ? "operator" : Visual.active ? "visual" : ""); } + cur_frame = old->parent; } diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index dbc080a1ad..c6180a84e0 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -13,18 +13,20 @@ /// Pending atom(s). Multiple atoms may queue; they cascade as a batch (mc_clock_edge). extern CmdAtomVec g_atoms; -/// Pre-command state sampled at normal_execute() entry; atom_cmd_end() diffs it to classify. -typedef struct { +/// Pre-command state sampled at entry + storage for its "staged" atom. atom_cmd_end() finalizes it. +typedef struct CmdFrame CmdFrame; +struct CmdFrame { pos_T pos; ///< Cursor position. const buf_T *buf; ///< Current buffer. varnumber_T tick; ///< b:changedtick VisualState visual; ///< Visual-mode state (active/start/mode are diffed). bool keytyped; ///< KeyTyped - uint64_t pushes; ///< `atom_pushes` (total atoms ever pushed). + uint64_t captures; ///< Capture counter. bool follow; ///< mc_following() ("q=") bool consumers; ///< Capture is skipped if there are no consumers (for performance). Timestamp reg_ts; ///< Max register timestamp (to detect a per-cursor register write). - bool staged; ///< atom_staged() -} CmdBaseline; + CmdAtom staged; ///< Atom staged in this frame. `keys == NULL`: none. + CmdFrame *parent; ///< Enclosing frame (nested normal_execute()); NULL at toplevel. +}; #include "input_cmdatom.h.generated.h" diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 47f07e717b..161cc1d6e6 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -1094,8 +1094,8 @@ static int normal_execute(VimState *state, int key) static int depth = 0; depth++; - CmdBaseline atom_old; - atom_cmd_start(&atom_old); + CmdFrame frame; + atom_cmd_start(&frame); NormalState *s = (NormalState *)state; s->command_finished = false; @@ -1269,7 +1269,7 @@ static int normal_execute(VimState *state, int key) finish: normal_finish_command(s); - atom_cmd_end(&s->ca, &atom_old, depth == 1); + atom_cmd_end(&s->ca, &frame, depth == 1); xfree(s->ca.searchbuf); depth--; return 1; diff --git a/test/functional/editor/atom_testutil.lua b/test/functional/editor/atom_testutil.lua index 6a1d6de279..95f849cc41 100644 --- a/test/functional/editor/atom_testutil.lua +++ b/test/functional/editor/atom_testutil.lua @@ -4,9 +4,8 @@ local n = require('test.functional.testnvim')() local m = {} ---- Get buffer lines as a table. function m.get_lines() - return n.api.nvim_buf_get_lines(0, 0, -1, true) + return n.buf_lines(0) end --- vim.keycode(): |key-notation| => the raw bytes of the CmdAtom event's keys/lhs. @@ -26,10 +25,9 @@ function m.atoms_start() ]]) end ---- Gets the collected CmdAtom event-data (the events are deferred: drain ---- the event loop first). +--- Gets the collected CmdAtom event-data. function m.atoms() - n.poke_eventloop() + n.poke_eventloop() -- CmdAtom is deferred, so drain the event loop first. return n.exec_lua('return _G.atoms') end diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index e8717d8109..43a5b965a3 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -258,7 +258,7 @@ describe('CmdAtom', function() ) end) - it('fires for typed input, never for programmatic sources', function() + it('fires for user input, not programmatic sources', function() atoms_start() -- Drain deferred CmdAtom events, then return + clear the collected list. local function take() @@ -334,7 +334,7 @@ describe('CmdAtom', function() eq(1, #take()) end) - it('visual atom keys re-execute on replay; unreplayable ops fall back to equal-size', function() + it('visual atom replay; fallback to equal-size if unreplayable', 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' }) @@ -344,19 +344,20 @@ describe('CmdAtom', function() 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. + + -- 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|). + + -- Builtin "." replays the same keysequence, not Vim's equal-size reselect. 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 = {} @@ -372,11 +373,13 @@ describe('CmdAtom', function() 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 + + -- 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') @@ -384,6 +387,7 @@ describe('CmdAtom', function() 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' }) @@ -391,6 +395,42 @@ describe('CmdAtom', function() eq({ '\r\rcd', 'efgh' }, get_lines()) feed('j0.') eq({ '\r\rcd', '\r\rgh' }, get_lines()) + + -- ":norm" mapping that extends the selection: "." replays it. #41323 + command('xmap normal! $') + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb' }) + feed('gg0vd') -- "$" in Visual is one past EOL, so "d" swallows the newline (= plain v$d) + eq({ 'bbb' }, get_lines()) + command('let v:errmsg = ""') + feed('.') + eq('', api.nvim_get_vvar('errmsg')) + eq('n', fn.mode()) -- Not in Visual. + eq({ '' }, get_lines()) + + -- ":norm" captured as nested subatoms: the atom is "ved", not the . + command('xmap normal! e') + api.nvim_buf_set_lines(0, 0, -1, true, { 'one two three', 'four five six' }) + feed('gg0vd') + eq({ ' two three', 'four five six' }, get_lines()) + eq({ type = 'visual', keys = 'ved' }, pick(atom_last(), 'type', 'keys')) + feed('j0.') + eq({ ' two three', ' five six' }, get_lines()) + + -- Buffer-editing is unreplayable (void), so "." fallsback to equal-size reselect. + command('xmap call append(1, "X")') + api.nvim_buf_set_lines(0, 0, -1, true, { 'ab', 'cd' }) + feed('gg0vld') + eq({ '', 'X', 'cd' }, get_lines()) + feed('3gg0.') + eq({ '', 'X', '' }, get_lines()) + + -- A search-extended selection re-executes: the payload travels in the collected keys. + api.nvim_buf_set_lines(0, 0, -1, true, { 'ab META x', 'cdef META y' }) + feed('gg0v/METAd') + eq({ 'ETA x', 'cdef META y' }, get_lines()) + eq({ type = 'visual', keys = k('v/METAd') }, pick(atom_last(), 'type', 'keys')) + feed('j0.') + eq({ 'ETA x', 'ETA y' }, get_lines()) end) it('a mapping can repeat the last visual atom', function() diff --git a/test/functional/legacy/094_visual_mode_operators_spec.lua b/test/functional/legacy/094_visual_mode_operators_spec.lua index 74f3f0cee9..e04cf39154 100644 --- a/test/functional/legacy/094_visual_mode_operators_spec.lua +++ b/test/functional/legacy/094_visual_mode_operators_spec.lua @@ -105,7 +105,7 @@ describe('Visual mode and operator', function() feed_command([[call setline(1, 'KiwiRaspberryDateWatermelonPeach')]]) feed_command('/^Kiwi') feed('vWcNol.fD2vd.') - expect('NoNoberry') + expect('NoNo') -- Same, using the vmap that maps to an Ex command. feed_command([[call setline(1, 'JambuRambutanBananaTangerineMango')]]) diff --git a/test/old/testdir/test_visual.vim b/test/old/testdir/test_visual.vim index 6c7ae13c1d..461328ee58 100644 --- a/test/old/testdir/test_visual.vim +++ b/test/old/testdir/test_visual.vim @@ -553,7 +553,7 @@ func Test_visual_mode_maps() call setline(1, 'KiwiRaspberryDateWatermelonPeach') call cursor(1, 1) exe "normal vWcNo\l.fD2vd." - call assert_equal('NoNoberry', getline(1)) + call assert_equal('NoNo', getline(1)) call setline(1, 'JambuRambutanBananaTangerineMango') call cursor(1, 1)