diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index a2896463e7..ab69a1ea97 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -461,6 +461,7 @@ CmdAtom After a user action (an "atom" of input): any Raw internal bytes (use |keytrans()| to get key-notation): feed to |nvim_feedkeys()| (mode "n") to replay. Empty: unreplayable. + Nil: lossy capture, replay `lhs` (mode "m"). - lhs: User input, before it is resolved/translated. Mapping LHS plus any payload it read (|getchar()|), macro register @@ -468,21 +469,12 @@ CmdAtom After a user action (an "atom" of input): any `keys`. - motionforce |forced-motion|: "v", "V", or "" (|key-notation|). - - moved: Cursor moved. To detect a "motion": - `moved and not changed and not operator` + - moved: Cursor moved. - operator: Operator: "d", "g@", "zf", …. |key-notation|, like `cmd`. - - pending: The next atom completes what - this mapping started: "mapping", - "operator", "visual". - pos: Start position: [row, col], 1-indexed row, 0-indexed col. - reg: Register name. - - remap: `keys` cannot replay this atom: the - mapping reads its own arguments, or resolved - to no keys. Re-run `lhs` instead. Empty - `keys` WITHOUT `remap` is unreplayable (a - Visual op with a tainted selection). - text: Payload text: inserted text of an insert session (after its last cursor-move, like |quote.|), Ex cmdline, or search. @@ -499,7 +491,9 @@ CmdAtom After a user action (an "atom" of input): any absolute/shared navigation state (|jumplist|, marks, |star|). - "mapping" - - "motion" + - "motion": Non-jump motion. May emit + `moved=false` ("w" at buffer end, failed + "fx", …). - "mouse" - "normal": Non-motion/jump command ("u", CTRL-R, "za", CTRL-W, …). diff --git a/runtime/doc/dev_arch.txt b/runtime/doc/dev_arch.txt index 8d594e0ab6..ab65823cb3 100644 --- a/runtime/doc/dev_arch.txt +++ b/runtime/doc/dev_arch.txt @@ -567,6 +567,8 @@ CONCEPTS cascade "dry-runs" spans to display the selection. - VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse, `gv`, a scroll that drags the cursor, …), thus not replayable. +- LOSSY: When capture lost part of a composite (payload with no capturing + atom, incomplete insert-session): `keys` cannot replay it, `lhs` can. IMPLEMENTATION diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index d3d7ddd612..c87d218e62 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -53,52 +53,69 @@ produced. INPUT (`lhs`) RESOLUTION (`keys`) ~ dw "dw" Not translated. zfa{ "zfa{" Not an edit (`changed=false`). - x "dl" Translated builtin - ,d "dl" Mapping `:nnoremap ,d x` - @q "dl" Macro `@q = "x"` + x "dl" Translated builtin. + ,d "dl" Mapping `:nnoremap ,d x`. + @q "dl" Macro `@q = "x"`. "dlw" Composite `:nnoremap xw`: `type='mapping'`, subatoms "dl" and "w" concat to the composite keys. - ds) ":call …" "Payload" mapping (reads |getchar()| - e.g. vim-surround "ds'"), `remap=true` - ]q "" Lua mapping, `remap=true` - ,D "" Incomplete mapping (ended mid- - operation) `:nnoremap ,D d`, - `pending='operator'`, the NEXT atom - ("dw") completes it + ,Dw "dw" Incomplete mapping: ended mid- + operation (`:nnoremap ,D d`), + captures its continuation ("w"). + ds) ":call …)" "Payload" mapping (e.g. vim-surround + "ds'" reads |getchar()|). + ]q nil Lua mapping. Vd "" Unreplayable: viewport-dependent Visual sequence; `lhs` is only a hint/label. -To repeat an atom, feed `keys` (mode "n"), or `lhs` (mode "m"). Empty `keys` -without `remap` means the atom is unreplayable. >lua +To repeat an atom, feed `keys` (mode "n"), or `lhs` (mode "m") if `keys=nil`. >lua local function replay(a) - if not a.remap and a.keys == '' then - return -- Unreplayable Visual op. - end - vim.api.nvim_feedkeys(a.remap and a.lhs or a.keys, a.remap and 'm' or 'n', false) + vim.api.nvim_feedkeys(a.keys or a.lhs, a.keys and 'n' or 'm', false) end < *motion-repeat* Example: This "," mapping repeats the last "motion" ("zj", "3w", "fx", -"/pat", "]c") by checking `(moved and not changed)`. Drop the -`not ev.data.moved` condition to repeat any non-edit ("zz", "zfa{", CTRL-W_w): >lua +"/pat", "]c"). Drop the `pattern` to repeat any non-edit action, motion or +not ("zz", "zfa{", CTRL-W_w): >lua local last ---@type vim.event.cmdatom.data? vim.api.nvim_create_autocmd('CmdAtom', { + pattern = 'motion', callback = function(ev) - if not ev.data.moved or ev.data.changed or ev.data.lhs == ',' then - return -- Skip non-motions, edits, and this mapping itself. + if ev.data.changed or ev.data.lhs == ',' then + return -- Skip edits, and this mapping itself. end last = ev.data end, }) vim.keymap.set('n', ',', function() - -- CmdAtom is deferred: schedule the replay, in case "," follows a motion. + -- CmdAtom is deferred; schedule the replay, in case "," follows a motion. vim.schedule(function() if last then - replay(last) + vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false) + end + end) + end) +< + *dot-repeat* +Example: This "." mapping repeats ANY edit, including operations provided by +plugins, without the need for "announcement" via vim-repeat or similar. >lua + + local last ---@type vim.event.cmdatom.data? + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + if ev.data.changed and ev.data.lhs ~= '.' then + last = ev.data + end + end, + }) + vim.keymap.set('n', '.', function() + -- CmdAtom is deferred; schedule the replay, in case "." follows an edit. + vim.schedule(function() + if last then + vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false) end end) end) diff --git a/runtime/lua/vim/_meta/events.lua b/runtime/lua/vim/_meta/events.lua index fd18121c44..a2ea37c64e 100644 --- a/runtime/lua/vim/_meta/events.lua +++ b/runtime/lua/vim/_meta/events.lua @@ -11,15 +11,13 @@ error('Cannot require a meta file') --- @field cmd? string Command/motion/object name ("w", "f", "iw", "gJ"). --- @field cmdarg? string Operand of `cmd` ("fx" => "x"). --- @field count? integer Effective count. ---- @field keys string Resolved keysequence, raw bytes: feed to nvim_feedkeys() to replay. ---- @field lhs? string High-level user input: mapping LHS + any payload it read, or macro register ("gj", "ds'", "@q"). Raw bytes. +--- @field keys? string Resolved keysequence, raw bytes. Replay via `feedkeys(keys, 'n')`. Nil: lossy capture, replay via `feedkeys(lhs, 'm')` instead. Empty: unreplayable. +--- @field lhs string High-level user input: mapping LHS + any payload it read, or macro register ("gj", "ds'", "@q"). Raw bytes. --- @field motionforce? 'v'|'V'|'' forced-motion type. --- @field moved? boolean Moved the cursor. --- @field operator? string Operator name ("d", "g~", "g@"). key-notation. ---- @field pending? 'mapping'|'operator'|'visual' The next atom completes what this started. --- @field pos? [integer,integer] Cursor before the action: 1-indexed row, 0-indexed column. --- @field reg? string Register name. ---- @field remap? true `keys` cannot replay this (the mapping reads its own args): feed `lhs` with remapping. --- @field text? string Inserted text, or the Ex/search cmdline. --- @field type 'excmd'|'insert'|'jump'|'mapping'|'motion'|'mouse'|'normal'|'operator'|'scroll'|'visual' --- @field undoseq? integer Undo state after the action (`undotree().seq_cur`). Decreases on undo. diff --git a/src/nvim/ex_getln.c b/src/nvim/ex_getln.c index 5a50b3842d..a0f42c204d 100644 --- a/src/nvim/ex_getln.c +++ b/src/nvim/ex_getln.c @@ -4712,8 +4712,10 @@ void get_user_input(const typval_T *const argvars, typval_T *const rettv, const const int save_ex_normal_busy = ex_normal_busy; ex_normal_busy = 0; + atom_payload_start(); rettv->vval.v_string = getcmdline_prompt(secret ? NUL : '@', p, get_echo_hl_id(), xp_type, xp_arg, input_callback, false, NULL); + atom_payload_end(); ex_normal_busy = save_ex_normal_busy; callback_free(&input_callback); diff --git a/src/nvim/input.c b/src/nvim/input.c index e84f81de18..43af7d4f8b 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -1951,6 +1951,7 @@ static void getchar_common(typval_T *argvars, typval_T *rettv, bool allow_number if (!simplify) { no_reduce_keys++; } + atom_payload_start(); while (true) { if (cursor_flag == 'm' || (cursor_flag == NUL && msg_col > 0)) { ui_cursor_goto(msg_row, msg_col); @@ -1990,6 +1991,7 @@ static void getchar_common(typval_T *argvars, typval_T *rettv, bool allow_number } break; } + atom_payload_end(); no_mapping--; allow_keys--; if (!simplify) { @@ -2245,7 +2247,7 @@ static int char_iter(const uint8_t **itp, int nomap) /// - When there is no match yet, return map_result_nomatch, need to get more /// typeahead. /// - On failure (out of memory) return map_result_fail. -static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth) +static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth, bool advance) FUNC_ATTR_NONNULL_ARG(1) { mapblock_T *mp = NULL; @@ -2495,7 +2497,7 @@ static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth) (size_t)(keylen - typebuf.tb_maplen)); // A typed key sequence resolved this mapping (not a nested expansion: // those keys come from another mapping): open its composite. - atom_map_start(mp->m_keys, (size_t)mp->m_keylen); + atom_map_start(mp->m_keys, (size_t)mp->m_keylen, !advance); } cmd_silent = (typebuf.tb_silent > 0); @@ -2779,7 +2781,8 @@ static int vgetorpeek(bool advance) break; } else if (typebuf.tb_len > 0) { // Check for a mapping in "typebuf". - map_result_T result = (map_result_T)handle_mapping(&keylen, &timedout, &mapdepth); + map_result_T result = (map_result_T)handle_mapping(&keylen, &timedout, &mapdepth, + advance); if (result == map_result_retry) { // try mapping again diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 00b99afe2d..c17ec92522 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -19,6 +19,7 @@ #include "nvim/autocmd.h" #include "nvim/buffer.h" #include "nvim/eval/typval_defs.h" +#include "nvim/eval/vars.h" #include "nvim/ex_docmd.h" #include "nvim/globals.h" #include "nvim/input.h" @@ -34,6 +35,7 @@ #include "nvim/ops.h" #include "nvim/option_vars.h" #include "nvim/register.h" +#include "nvim/state.h" #include "nvim/state_defs.h" #include "nvim/strings.h" #include "nvim/vim_defs.h" @@ -50,11 +52,19 @@ static void mc_vsel_refresh(void) { } +static void mc_vsel_clear(void) +{ +} + static bool mc_following(void) { return false; } +static void mc_clock_edge(bool map_edit) +{ +} + CmdAtomVec g_atoms = KV_INITIAL_VALUE; /// 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)?". @@ -63,13 +73,19 @@ static uint64_t atom_captures = 0; static bool atom_suppressed = false; /// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions. static bool map_edit = false; +/// Ticks per command frame (CmdFrame.id). +static uint64_t frame_id = 0; /// Accumulating composite atom: an executing mapping/macro. See `vatom` for Visual composite. static struct { CmdAtomVec atoms; ///< Subatoms of the mapping/macro. char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting). bool queued; ///< A cascadable atom was queued (g_atoms) while collecting. + bool lossy; ///< Capture lost part of the mapping (incomplete insert, payload with no + ///< capturing atom): `keys` cannot replay it, `lhs` can. bool macro; ///< Macro execution: captured as an "@x"-labeled atom. + uint64_t frame; ///< CmdFrame already executing when a lookahead resolved this mapping + ///< ("f(" + mapped key in one batch). 0: none. CmdOrigin origin; ///< State at start. } composite; @@ -96,20 +112,18 @@ static struct { /// Per-command capture scratch. static struct { - bool redo_pending; ///< The command prepped the change atom (prep_redo*()). + uint64_t redo_frame; ///< CmdFrame that prepped (prep_redo*()); frame end captures it. 0: none. char *cmdline; ///< The ":" payload captured at cmdline accept. NULL: none. ///< Note: search payloads ("/pat") travel on `cmdarg.searchbuf`. bool ins_cascaded; ///< Did the command's insert-session already cascade? bool op_global; ///< Already applied to every cursor (undo, "g CTRL-A"): must not cascade. } curcmd; -/// Interactively typed keys of the executing command. Two slices mark keys read by getchar(), which -/// the redo body never sees: 'operatorfunc' input, and the mapping payload ("ds'" reads "'"). +/// Interactively typed keys of the executing command. Collected during a composite (its `lhs` +/// suffix) and eval-read (the frame's payload slice, see atom_payload_start()). static struct { kvec_t(uint8_t) keys; - bool opfunc_active; ///< Collecting 'operatorfunc' input. - size_t opfunc_start, opfunc_end; ///< opfunc slice: keys[opfunc_start..opfunc_end) - size_t map_start; ///< mapping slice: keys[map_start..kv_size(keys)) + size_t map_start; ///< This composite's slice: keys[map_start..kv_size(keys)), its lhs suffix. } typed; static const char *const type_names[] = { @@ -332,7 +346,9 @@ static Dict atom_dict(const CmdAtom *atom) } // keys/lhs are RAW bytes (typeahead encoding). const char *keys = atom->keys != NULL ? atom->keys : ""; - PUT(d, "keys", CSTR_TO_OBJ(keys)); + if (!atom->remap) { + PUT(d, "keys", CSTR_TO_OBJ(keys)); + } PUT(d, "lhs", CSTR_TO_OBJ(atom->lhs != NULL && *atom->lhs != NUL ? atom->lhs : keys)); if (*force != NUL) { PUT(d, "motionforce", CSTR_TO_OBJ(force)); @@ -345,9 +361,6 @@ static Dict atom_dict(const CmdAtom *atom) if (spec->regname != 0) { PUT(d, "reg", CSTR_TO_OBJ(regname)); } - if (atom->remap) { - PUT(d, "remap", BOOLEAN_OBJ(true)); - } if (atom->text != NULL && *atom->text != NUL) { PUT(d, "text", CSTR_TO_OBJ(atom->text)); } @@ -356,7 +369,7 @@ static Dict atom_dict(const CmdAtom *atom) } /// Schedules a CmdAtom event. -static void atom_emit(const CmdAtom *atom, const char *pending) +static void atom_emit(const CmdAtom *atom) { if (!has_event(EVENT_CMDATOM)) { return; @@ -369,19 +382,18 @@ static void atom_emit(const CmdAtom *atom, const char *pending) } PUT(data, "atoms", ARRAY_OBJ(atoms)); } - if (*pending != NUL) { - PUT(data, "pending", CSTR_TO_OBJ(pending)); - } buf_T *buf = atom->origin.buf.br_buf != NULL ? atom->origin.buf.br_buf : curbuf; aucmd_defer(EVENT_CMDATOM, (char *)type_names[atom->type], NULL, AUGROUP_ALL, buf, NULL, &DICT_OBJ(data)); api_free_dict(data); } -/// Emits a CmdAtom event, or collects it as a subatom of a composite. If `cascade` is true, queues -/// a copy for mcursor cascade. +/// Emits a CmdAtom event, or collects it as a subatom of an open scope. If `cascade` is true, +/// queues a copy for mcursor cascade. Takes ownership of the atom's allocated members. /// -/// Takes ownership of the atom's allocated members. Caller sets `atom.changed`. +/// Both scopes (composite, Visual session) can be open. Routed by scope kind, not depth: +/// a collecting Visual session takes the atom, its atom then lands in the composite (",v"+"d"). +/// Depth would invert "v@q", where the "@q" composite opened LAST yet collects nothing. void atom_push_raw(bool cascade, CmdAtom atom) { assert(atom.keys != NULL); @@ -392,7 +404,6 @@ void atom_push_raw(bool cascade, CmdAtom atom) atom.undoseq = atom_origin_undoseq(atom.origin); } if (atom_visual_pending()) { - // 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++; @@ -408,15 +419,27 @@ void atom_push_raw(bool cascade, CmdAtom atom) last->changed = atom.changed; } } - if (cascade && composite.lhs != NULL) { - composite.queued = true; + // `composite.frame`: the command that was executing when a peek opened the composite is not part + // of it. + const bool collect = composite.lhs != NULL + && (cur_frame == NULL || cur_frame->id != composite.frame); + if (cascade) { + CmdAtom copy = atom; + copy.keys = xstrdup(atom.keys); + copy.text = NULL; // replay (mc_execute()) reads only type/keys/remap: not the text, + copy.lhs = NULL; // nor the label, + copy.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // nor the decomposition + kv_push(g_atoms, copy); + if (collect) { + composite.queued = true; + } } - if (composite.lhs != NULL) { + if (collect) { kv_push(composite.atoms, atom); } else { if (atom.type != kAInsertSpan) { // Spans are cascade-internal; only emit the whole session (kAInsert). - atom_emit(&atom, ""); + atom_emit(&atom); } atom_free(&atom); } @@ -504,6 +527,8 @@ static void atom_composite_start(const char *lhs, size_t len) xfree(composite.lhs); composite.lhs = xmemdupz(lhs, len); composite.queued = false; + composite.lossy = false; + composite.frame = 0; composite.origin = atom_origin(); } @@ -511,15 +536,14 @@ static void atom_composite_start(const char *lhs, size_t len) /// /// :nnoremap gj ik$ /// "gj" => CmdAtom{ .lhs="gj", .keys="1ik$", kAMapping } -static void atom_composite_end(const char *pending) +static void atom_composite_end(void) { composite.macro = false; // "@x" capture ends with its composite. if (composite.lhs == NULL) { return; } - // The mapping read a payload via getchar() ("ds'"); replaying `keys` would prompt again. - // Consumer should rerun `lhs` (which carries the payload) in "remap" mode. - const bool remap = kv_size(typed.keys) > typed.map_start || kv_size(composite.atoms) == 0; + // LHS-replay when the capture is lossy, or captured nothing (Ex/Lua edits). + const bool remap = composite.lossy || kv_size(composite.atoms) == 0; char *lhs = atom_composite_lhs(); XFREE_CLEAR(composite.lhs); CmdAtom atom; @@ -546,7 +570,7 @@ static void atom_composite_end(const char *pending) atom.atoms = composite.atoms; // Subatoms. composite.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // Reset. } - atom_emit(&atom, pending); + atom_emit(&atom); atom_free(&atom); } @@ -571,7 +595,9 @@ bool atom_is_user_cmd(void) /// typed "i", mapped "gj" => true; "." (stuffed redo), "@r" => false static bool atom_is_user_input(void) { - return KeyTyped || (atom_is_user_cmd() && typebuf_maplen() > 0); + // An open composite is user input even after its keys were consumed (":nnoremap ,i i"). + return KeyTyped + || (atom_is_user_cmd() && (typebuf_maplen() > 0 || atom_composite_active())); } /// Suppresses atom pushes. For internal operators. @@ -635,11 +661,20 @@ unsigned atom_key_class(int cmd, int arg) return kKeyJump; case Ctrl_T: return kKeyJump | kKeyInsFlush; + // Multiplexed: one nv_cmds entry => many commands. NV_MOTION cannot tag them; char 2 decides. case 'g': - return (arg == ';' || arg == ',') ? kKeyJump : 0; + if (arg == ';' || arg == ',') { + return kKeyJump; + } + return strchr("gjk0^$_meEoM", arg) != NULL ? kKeyMotion : 0; case '[': case ']': - return arg == 'C' ? kKeyJump : 0; // "]C"/"[C": jump to the next/previous cursor + if (arg == 'C') { + return kKeyJump; // "]C"/"[C": jump to the next/previous cursor + } + return strchr("[](){}mMcsz#*/", arg) != NULL ? kKeyMotion : 0; + case 'z': + return (arg == 'j' || arg == 'k') ? kKeyMotion : 0; case '*': case '#': case '\'': @@ -691,22 +726,53 @@ void atom_cmdline_set(int firstc, const char *line, size_t len) curcmd.cmdline = xmemdupz(line, len); } -/// Opens/closes the 'operatorfunc' slice of the typed-key stream. -void atom_opfunc_slice(bool active) +/// True while collecting typed keys: during a composite (for `lhs`), or a CmdFrame's payload slice. +static bool atom_typed_collecting(void) { - typed.opfunc_active = active; - if (active) { - typed.opfunc_start = kv_size(typed.keys); + return atom_composite_active() + || (cur_frame != NULL && cur_frame->payload_start != SIZE_MAX); +} + +/// Opens the CmdFrame's payload slice: keys from getchar(), input() append to `CmdAtom.keys`. +void atom_payload_start(void) +{ + if (cur_frame != NULL && cur_frame->payload_start == SIZE_MAX && !mc_replaying()) { + cur_frame->payload_start = cur_frame->payload_end = kv_size(typed.keys); } - typed.opfunc_end = kv_size(typed.keys); +} + +/// Closes (or extends) the payload slice. +void atom_payload_end(void) +{ + if (cur_frame != NULL && cur_frame->payload_start != SIZE_MAX && !mc_replaying()) { + cur_frame->payload_end = kv_size(typed.keys); + } +} + +/// Drains the CmdFrame's payload slice to `CmdAtom.keys`. +static void atom_payload_append(CmdAtom *atom, CmdFrame *frame) +{ + size_t plen = frame->payload_start == SIZE_MAX ? 0 : frame->payload_end - frame->payload_start; + if (plen == 0 || atom->keys == NULL) { + return; + } + size_t klen = strlen(atom->keys); + atom->keys = xrealloc(atom->keys, klen + plen + 1); + memcpy(atom->keys + klen, typed.keys.items + frame->payload_start, plen); + atom->keys[klen + plen] = NUL; + frame->payload_start = SIZE_MAX; } /// Collects a typed key (gotchars()) into the stream. void atom_typed_add(const uint8_t *chars, size_t len) { - if (!typed.opfunc_active && !atom_composite_active()) { + if (mc_replaying() || !atom_typed_collecting()) { return; } + if (len == 3 && chars[0] == K_SPECIAL + && (atom_key_class(TERMCAP2KEY(chars[1], chars[2]), NUL) & kKeySynthetic)) { + return; // Not user input: K_IGNORE from a mapping resolved during peek/K_EVENT/… + } for (size_t i = 0; i < len; i++) { kv_push(typed.keys, chars[i]); } @@ -716,7 +782,7 @@ void atom_typed_add(const uint8_t *chars, size_t len) /// into typeahead and will be collected again (ungetchars()). void atom_typed_del(size_t len) { - if (!typed.opfunc_active && !atom_composite_active()) { + if (mc_replaying() || !atom_typed_collecting()) { return; } kv_size(typed.keys) -= MIN(len, kv_size(typed.keys)); @@ -729,16 +795,18 @@ static void atom_redo_reset(void) if (!atom_is_user_cmd()) { return; } - curcmd.redo_pending = false; + curcmd.redo_frame = 0; curcmd.ins_cascaded = false; XFREE_CLEAR(curcmd.cmdline); - // The opfunc slice resets per command; the stream itself is truncated only once the - // mapping slice ends too (with its composite). + // The stream is truncated only once the mapping slice ends too (with its composite). if (!atom_composite_active()) { kv_size(typed.keys) = 0; typed.map_start = 0; + // Truncation voids the payload slice. + for (CmdFrame *frame = cur_frame; frame != NULL; frame = frame->parent) { + frame->payload_start = SIZE_MAX; + } } - typed.opfunc_start = typed.opfunc_end = kv_size(typed.keys); } /// Marks the running command as already applying to every cursor (see `curcmd.op_global`). @@ -749,15 +817,15 @@ void atom_op_global_set(void) } /// Claims the prepped redo as the command's atom (prep_redo()). Only toplevel user commands (a -/// nested redo-prep is not an atom). Declines Ex/Lua operators. +/// nested redo-prep is not an atom). Declines Lua operators. void atom_redo_set(CmdSpec spec) { - if (spec.cmd == ':' || spec.cmd == K_COMMAND || spec.cmd == K_LUA) { + if (spec.cmd == K_LUA) { atom_redo_reset(); return; } if (atom_is_user_cmd()) { - curcmd.redo_pending = true; + curcmd.redo_frame = cur_frame != NULL ? cur_frame->id : 0; } } @@ -787,7 +855,9 @@ void atom_stuff_start(const cmdarg_T *cap) } /// Starts accumulating a composite for a mapping resolved from typed keys (vgetorpeek()). -void atom_map_start(const char *lhs, size_t len) +/// +/// @param peeked Resolved by a peek: the executing command did not consume the mapping's keys. +void atom_map_start(const char *lhs, size_t len, bool peeked) { if (!atom_has_consumers() || reg_executing != 0 || ex_normal_busy != 0 || !(State & MODE_NORMAL) @@ -798,9 +868,22 @@ void atom_map_start(const char *lhs, size_t len) // Mapping resolved from another's trailing prefix ("nmap x j," + "nnoremap ,w w"): end the // pending composite, so each `lhs` owns only the keys it produced. typed.map_start = kv_size(typed.keys); - atom_composite_end("mapping"); + atom_composite_end(); + } + const char *op = get_vim_var_str(VV_OP); // v:operator + if (get_real_state() == MODE_OP_PENDING && *op != NUL) { + // Op-pending mapping (:omap) continues the operator (vim-sneak "dz(b"). + assert(lhs[len] == NUL); + char *full = concat_str(op, lhs); + atom_composite_start(full, strlen(full)); + xfree(full); + typed.map_start = kv_size(typed.keys); + return; } atom_composite_start(lhs, len); + if (peeked && cur_frame != NULL) { + composite.frame = cur_frame->id; + } typed.map_start = kv_size(typed.keys); } @@ -809,6 +892,8 @@ static void atom_visual_reset(void) { vatom.state = kVatomNone; atoms_free(&vatom.atoms); + vatom.origin = (CmdOrigin){ 0 }; + mc_vsel_clear(); } /// Visual atom is pending. A void session still accumulates, for the `lhs` label. @@ -1076,6 +1161,10 @@ void atom_ins_end(const InsSession *session, bool busy) bool visual = session->vis != kVInsNone; if (!session->typed || busy || restart_edit != 0 || !atom_buf_has_consumers() || (visual && session->vis != kVInsKeys)) { + if (session->typed && (busy || restart_edit != 0) && atom_composite_active()) { + // Incomplete session (i_CTRL-O): its resolution is never captured. + composite.lossy = true; + } return; } atom_ins_push(session, true); @@ -1105,12 +1194,15 @@ void atom_cmd_start(CmdFrame *old) old->visual = Visual; old->keytyped = KeyTyped; old->captures = atom_captures; + old->id = ++frame_id; // 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 = (CmdAtom){ 0 }; + old->payload_start = SIZE_MAX; + old->payload_end = 0; old->parent = cur_frame; cur_frame = old; curcmd.op_global = false; @@ -1122,7 +1214,7 @@ void atom_cmd_start(CmdFrame *old) /// /// 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 CmdFrame *old, bool toplevel) +static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) { if (mc_replaying() || atom_suppressed) { return; @@ -1170,7 +1262,8 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) if (!old->visual.active) { atom_visual_reset(); // Decided once, at session start. - vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed; + vatom.state = (old->keytyped || (atom_composite_active() && atom_is_user_cmd())) + ? kVatomTyped : kVatomFed; vatom.origin = old->origin; } // Decided by the session (not atom_capturable()), so fed selections (":normal! vjd") still @@ -1221,31 +1314,21 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) && !scroll_cmd) || special_motion; bool changed = atom_origin_changed(old->origin); - bool moved = atom_origin_moved(old->origin); - bool motion = moved - && !changed - && !finish_op && !jump_cmd - && ((ca->cmdchar > 0 && ca->cmdchar < 0x100 - && strchr("/?:!Qq", ca->cmdchar) == NULL) - || special_motion); + // Note: an operator's motion belongs to the operator (`finish_op`). + bool motion = (nv_is_motion(ca->cmdchar) || special_motion) && !changed + && !finish_op && !jump_cmd; // Mapping-internal motions are part of its recipe: queue them, the clock edge decides. bool follow = (mc_following() || mapped) && motion; // // Route: decide the atom type and push it. // - if (curcmd.redo_pending && !mouse_cmd) { + if (curcmd.redo_frame == old->id && !mouse_cmd) { // Not for mouse commands (middle-click paste): pasting at every cursor would use // viewport-dependent positions. CmdAtom atom = atom_from_redo(kAOperator); - size_t plen = typed.opfunc_end - typed.opfunc_start; - if (atom.keys != NULL && plen > 0) { - // The opfunc's payload is absent from the captured redo: append it. - size_t klen = strlen(atom.keys); - atom.keys = xrealloc(atom.keys, klen + plen + 1); - memcpy(atom.keys + klen, typed.keys.items + typed.opfunc_start, plen); - atom.keys[klen + plen] = NUL; - } + // The payload ('operatorfunc' getchar()) is not in the captured redo, append it. + atom_payload_append(&atom, old); // Cascade only on an OBSERVABLE effect: an edit or register write. A redoable operator that // did neither is a no-op (vim-surround "ysa[" whose surround char was ). bool effect = changed || reg_max_ts(true) > old->reg_ts; @@ -1261,10 +1344,11 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf); atom.origin = old->origin; atom_push(false, atom); - } else if (!vis && curcmd.cmdline != NULL - && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) { + } 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(kAExcmd, ca, curcmd.cmdline); + // Payload read during the cmdline execution (`ds)` getchar() => ")"). + atom_payload_append(&atom, old); atom.origin = old->origin; atom_push(false, atom); } else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) { @@ -1297,24 +1381,34 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) // Not replayable: edited buffer during selection, so the keys do not describe the change. vatom.state |= kVatomVoid; } - if (Visual.active && user && toplevel) { + if (Visual.active && user && old->parent == NULL) { mc_vsel_refresh(); } } /// 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) +void atom_cmd_end(cmdarg_T *ca, CmdFrame *old) { - atom_capture_cmd(ca, old, toplevel); + atom_capture_cmd(ca, old); atom_stage_flush(old); + if (atom_composite_active() && old->payload_start != SIZE_MAX + && old->payload_end > old->payload_start) { + // An eval-read payload with no capturing atom (Lua mapping's getchar()). + composite.lossy = true; + } // 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 // one unit; likewise for a macro's LAST command, which may stuff a translation ("x" => "dl"). - if (toplevel && typebuf_typed() && stuff_empty()) { + if (old->parent == NULL && !mc_replaying() && typebuf_typed() && stuff_empty()) { + mc_clock_edge(map_edit); map_edit = false; - atom_composite_end(ca->oap->op_type != OP_NOP ? "operator" : Visual.active ? "visual" : ""); + // Mapping contains its continuation. While op-pending, selection-active, or insert-will-resume + // (i_CTRL-O), composite keeps collecting: ",Dw" (":nnoremap ,D d") is one atom, `keys="dw"`. + if (ca->oap->op_type == OP_NOP && !Visual.active && restart_edit == 0) { + atom_composite_end(); + } } cur_frame = old->parent; } diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index b8875163f6..0f09857c95 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -20,10 +20,13 @@ struct CmdFrame { VisualState visual; ///< Visual-mode state (active/start/mode are diffed). bool keytyped; ///< KeyTyped uint64_t captures; ///< Capture counter. + uint64_t id; ///< Identifies this frame (see `composite.frame`). 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). - CmdAtom staged; ///< Atom staged in this frame. `keys == NULL`: none. + CmdAtom staged; ///< Atom staged in this frame (`keys=NULL`: none). + size_t payload_start; ///< Payload slice of key stream (`SIZE_MAX`: none): + size_t payload_end; ///< `typed.keys[payload_start..payload_end)` CmdFrame *parent; ///< Enclosing frame (nested normal_execute()); NULL at toplevel. }; diff --git a/src/nvim/input_cmdatom_defs.h b/src/nvim/input_cmdatom_defs.h index 71fd32ea1c..6c6940fa7f 100644 --- a/src/nvim/input_cmdatom_defs.h +++ b/src/nvim/input_cmdatom_defs.h @@ -71,8 +71,8 @@ struct CmdAtom { int undoseq; ///< Undo state at settlement. Not monotonic (decreases on undo). bool changed; ///< The command changed the buffer. bool moved; ///< The command moved the cursor. - bool remap; ///< Replay `keys` w/ remap. For replay of a payload mapping (vim-surround "ds'"), - ///< which edits invisibly (:norm/Ex) and must rerun LHS instead of resolved keys. + bool remap; ///< If true, `keys` cannot replay: payload mapping (vim-surround "ds'") edits + ///< invisibly (:norm/Ex). Must replay `lhs` instead. }; /// Key classes (atom_key_class()). diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 5a617db00f..e804c5ab2f 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -164,6 +164,7 @@ typedef void (*nv_func_T)(cmdarg_T *cap); #define NV_NCW 0x200 // not allowed in command-line window #define NV_NCH_ARG 0x400 // second char is a typed operand (mark/register name), // not part of the command name (see NV_LANG for f/t/r) +#define NV_MOTION 0x800 // Motion command. // Generally speaking, every Normal mode command should either clear any // pending operator (with *clearop*()), or set the motion type variable @@ -190,15 +191,15 @@ static const struct nv_cmd { { Ctrl_E, nv_scroll_line, 0, true }, { Ctrl_F, nv_page, NV_STS, FORWARD }, { Ctrl_G, nv_ctrlg, 0, 0 }, - { Ctrl_H, nv_ctrlh, 0, 0 }, + { Ctrl_H, nv_ctrlh, NV_MOTION, 0 }, { Ctrl_I, nv_pcmark, 0, 0 }, - { NL, nv_down, 0, false }, + { NL, nv_down, NV_MOTION, false }, { Ctrl_K, nv_error, 0, 0 }, { Ctrl_L, nv_clear, 0, 0 }, - { CAR, nv_down, 0, true }, - { Ctrl_N, nv_down, NV_STS, false }, + { CAR, nv_down, NV_MOTION, true }, + { Ctrl_N, nv_down, NV_STS|NV_MOTION, false }, { Ctrl_O, nv_ctrlo, 0, 0 }, - { Ctrl_P, nv_up, NV_STS, false }, + { Ctrl_P, nv_up, NV_STS|NV_MOTION, false }, { Ctrl_Q, nv_visual, 0, false }, { Ctrl_R, nv_redo_or_register, 0, 0 }, { Ctrl_S, nv_ignore, 0, 0 }, @@ -216,23 +217,23 @@ static const struct nv_cmd { { Ctrl_RSB, nv_ident, NV_NCW, 0 }, { Ctrl_HAT, nv_hat, NV_NCW, 0 }, { Ctrl__, nv_error, 0, 0 }, - { ' ', nv_right, 0, 0 }, + { ' ', nv_right, NV_MOTION, 0 }, { '!', nv_operator, 0, 0 }, { '"', nv_regname, NV_NCH_NOP|NV_NCH_ARG|NV_KEEPREG, 0 }, { '#', nv_ident, 0, 0 }, - { '$', nv_dollar, 0, 0 }, - { '%', nv_percent, 0, 0 }, + { '$', nv_dollar, NV_MOTION, 0 }, + { '%', nv_percent, NV_MOTION, 0 }, { '&', nv_optrans, 0, 0 }, { '\'', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, true }, - { '(', nv_brace, 0, BACKWARD }, - { ')', nv_brace, 0, FORWARD }, + { '(', nv_brace, NV_MOTION, BACKWARD }, + { ')', nv_brace, NV_MOTION, FORWARD }, { '*', nv_ident, 0, 0 }, - { '+', nv_down, 0, true }, - { ',', nv_csearch, 0, true }, - { '-', nv_up, 0, true }, + { '+', nv_down, NV_MOTION, true }, + { ',', nv_csearch, NV_MOTION, true }, + { '-', nv_up, NV_MOTION, true }, { '.', nv_dot, NV_KEEPREG, 0 }, { '/', nv_search, 0, false }, - { '0', nv_beginline, 0, 0 }, + { '0', nv_beginline, NV_MOTION, 0 }, { '1', nv_ignore, 0, 0 }, { '2', nv_ignore, 0, 0 }, { '3', nv_ignore, 0, 0 }, @@ -243,71 +244,71 @@ static const struct nv_cmd { { '8', nv_ignore, 0, 0 }, { '9', nv_ignore, 0, 0 }, { ':', nv_colon, 0, 0 }, - { ';', nv_csearch, 0, false }, + { ';', nv_csearch, NV_MOTION, false }, { '<', nv_operator, NV_RL, 0 }, { '=', nv_operator, 0, 0 }, { '>', nv_operator, NV_RL, 0 }, { '?', nv_search, 0, false }, { '@', nv_at, NV_NCH_NOP|NV_NCH_ARG, false }, { 'A', nv_edit, 0, 0 }, - { 'B', nv_bck_word, 0, 1 }, + { 'B', nv_bck_word, NV_MOTION, 1 }, { 'C', nv_abbrev, NV_KEEPREG, 0 }, { 'D', nv_abbrev, NV_KEEPREG, 0 }, - { 'E', nv_wordcmd, 0, true }, - { 'F', nv_csearch, NV_NCH_ALW|NV_LANG, BACKWARD }, - { 'G', nv_goto, 0, true }, - { 'H', nv_scroll, 0, 0 }, + { 'E', nv_wordcmd, NV_MOTION, true }, + { 'F', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, BACKWARD }, + { 'G', nv_goto, NV_MOTION, true }, + { 'H', nv_scroll, NV_MOTION, 0 }, { 'I', nv_edit, 0, 0 }, { 'J', nv_join, 0, 0 }, { 'K', nv_ident, 0, 0 }, - { 'L', nv_scroll, 0, 0 }, - { 'M', nv_scroll, 0, 0 }, - { 'N', nv_next, 0, SEARCH_REV }, + { 'L', nv_scroll, NV_MOTION, 0 }, + { 'M', nv_scroll, NV_MOTION, 0 }, + { 'N', nv_next, NV_MOTION, SEARCH_REV }, { 'O', nv_open, 0, 0 }, { 'P', nv_put, 0, 0 }, { 'Q', nv_regreplay, 0, 0 }, { 'R', nv_Replace, 0, false }, { 'S', nv_subst, NV_KEEPREG, 0 }, - { 'T', nv_csearch, NV_NCH_ALW|NV_LANG, BACKWARD }, + { 'T', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, BACKWARD }, { 'U', nv_Undo, 0, 0 }, - { 'W', nv_wordcmd, 0, true }, + { 'W', nv_wordcmd, NV_MOTION, true }, { 'X', nv_abbrev, NV_KEEPREG, 0 }, { 'Y', nv_abbrev, NV_KEEPREG, 0 }, { 'Z', nv_Zet, NV_NCH_NOP|NV_NCW, 0 }, { '[', nv_brackets, NV_NCH_ALW, BACKWARD }, { '\\', nv_error, 0, 0 }, { ']', nv_brackets, NV_NCH_ALW, FORWARD }, - { '^', nv_beginline, 0, BL_WHITE | BL_FIX }, - { '_', nv_lineop, 0, 0 }, + { '^', nv_beginline, NV_MOTION, BL_WHITE | BL_FIX }, + { '_', nv_lineop, NV_MOTION, 0 }, { '`', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, false }, { 'a', nv_edit, NV_NCH, 0 }, - { 'b', nv_bck_word, 0, 0 }, + { 'b', nv_bck_word, NV_MOTION, 0 }, { 'c', nv_operator, 0, 0 }, { 'd', nv_operator, 0, 0 }, - { 'e', nv_wordcmd, 0, false }, - { 'f', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD }, + { 'e', nv_wordcmd, NV_MOTION, false }, + { 'f', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, FORWARD }, { 'g', nv_g_cmd, NV_NCH_ALW, false }, - { 'h', nv_left, NV_RL, 0 }, + { 'h', nv_left, NV_RL|NV_MOTION, 0 }, { 'i', nv_edit, NV_NCH, 0 }, - { 'j', nv_down, 0, false }, - { 'k', nv_up, 0, false }, - { 'l', nv_right, NV_RL, 0 }, + { 'j', nv_down, NV_MOTION, false }, + { 'k', nv_up, NV_MOTION, false }, + { 'l', nv_right, NV_RL|NV_MOTION, 0 }, { 'm', nv_mark, NV_NCH_NOP|NV_NCH_ARG, 0 }, - { 'n', nv_next, 0, 0 }, + { 'n', nv_next, NV_MOTION, 0 }, { 'o', nv_open, 0, 0 }, { 'p', nv_put, 0, 0 }, { 'q', nv_q, NV_NCH|NV_NCH_ARG, 0 }, { 'r', nv_replace, NV_NCH_NOP|NV_LANG, 0 }, { 's', nv_subst, NV_KEEPREG, 0 }, - { 't', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD }, + { 't', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, FORWARD }, { 'u', nv_undo, 0, 0 }, - { 'w', nv_wordcmd, 0, false }, + { 'w', nv_wordcmd, NV_MOTION, false }, { 'x', nv_abbrev, NV_KEEPREG, 0 }, { 'y', nv_operator, 0, 0 }, { 'z', nv_zet, NV_NCH_ALW, 0 }, - { '{', nv_findpar, 0, BACKWARD }, - { '|', nv_pipe, 0, 0 }, - { '}', nv_findpar, 0, FORWARD }, + { '{', nv_findpar, NV_MOTION, BACKWARD }, + { '|', nv_pipe, NV_MOTION, 0 }, + { '}', nv_findpar, NV_MOTION, FORWARD }, { '~', nv_tilde, 0, 0 }, // pound sign @@ -338,29 +339,29 @@ static const struct nv_cmd { { K_NOP, nv_nop, 0, 0 }, { K_INS, nv_edit, 0, 0 }, { K_KINS, nv_edit, 0, 0 }, - { K_BS, nv_ctrlh, 0, 0 }, - { K_UP, nv_up, NV_SSS|NV_STS, false }, + { K_BS, nv_ctrlh, NV_MOTION, 0 }, + { K_UP, nv_up, NV_SSS|NV_STS|NV_MOTION, false }, { K_S_UP, nv_page, NV_SS, BACKWARD }, - { K_DOWN, nv_down, NV_SSS|NV_STS, false }, + { K_DOWN, nv_down, NV_SSS|NV_STS|NV_MOTION, false }, { K_S_DOWN, nv_page, NV_SS, FORWARD }, - { K_LEFT, nv_left, NV_SSS|NV_STS|NV_RL, 0 }, - { K_S_LEFT, nv_bck_word, NV_SS|NV_RL, 0 }, - { K_C_LEFT, nv_bck_word, NV_SSS|NV_RL|NV_STS, 1 }, - { K_RIGHT, nv_right, NV_SSS|NV_STS|NV_RL, 0 }, - { K_S_RIGHT, nv_wordcmd, NV_SS|NV_RL, false }, - { K_C_RIGHT, nv_wordcmd, NV_SSS|NV_RL|NV_STS, true }, + { K_LEFT, nv_left, NV_SSS|NV_STS|NV_RL|NV_MOTION, 0 }, + { K_S_LEFT, nv_bck_word, NV_SS|NV_RL|NV_MOTION, 0 }, + { K_C_LEFT, nv_bck_word, NV_SSS|NV_RL|NV_STS|NV_MOTION, 1 }, + { K_RIGHT, nv_right, NV_SSS|NV_STS|NV_RL|NV_MOTION, 0 }, + { K_S_RIGHT, nv_wordcmd, NV_SS|NV_RL|NV_MOTION, false }, + { K_C_RIGHT, nv_wordcmd, NV_SSS|NV_RL|NV_STS|NV_MOTION, true }, { K_PAGEUP, nv_page, NV_SSS|NV_STS, BACKWARD }, { K_KPAGEUP, nv_page, NV_SSS|NV_STS, BACKWARD }, { K_PAGEDOWN, nv_page, NV_SSS|NV_STS, FORWARD }, { K_KPAGEDOWN, nv_page, NV_SSS|NV_STS, FORWARD }, - { K_END, nv_end, NV_SSS|NV_STS, false }, - { K_KEND, nv_end, NV_SSS|NV_STS, false }, - { K_S_END, nv_end, NV_SS, false }, - { K_C_END, nv_end, NV_SSS|NV_STS, true }, - { K_HOME, nv_home, NV_SSS|NV_STS, 0 }, - { K_KHOME, nv_home, NV_SSS|NV_STS, 0 }, - { K_S_HOME, nv_home, NV_SS, 0 }, - { K_C_HOME, nv_goto, NV_SSS|NV_STS, false }, + { K_END, nv_end, NV_SSS|NV_STS|NV_MOTION, false }, + { K_KEND, nv_end, NV_SSS|NV_STS|NV_MOTION, false }, + { K_S_END, nv_end, NV_SS|NV_MOTION, false }, + { K_C_END, nv_end, NV_SSS|NV_STS|NV_MOTION, true }, + { K_HOME, nv_home, NV_SSS|NV_STS|NV_MOTION, 0 }, + { K_KHOME, nv_home, NV_SSS|NV_STS|NV_MOTION, 0 }, + { K_S_HOME, nv_home, NV_SS|NV_MOTION, 0 }, + { K_C_HOME, nv_goto, NV_SSS|NV_STS|NV_MOTION, false }, { K_DEL, nv_abbrev, 0, 0 }, { K_KDEL, nv_abbrev, 0, 0 }, { K_UNDO, nv_kundo, 0, 0 }, @@ -431,6 +432,14 @@ bool nv_nchar_is_arg(int cmdchar) return idx >= 0 && (nv_cmds[idx].cmd_flags & (NV_LANG|NV_NCH_ARG)) != 0; } +/// True if `cmdchar` is a motion command. Multiplexed handlers (g, [, ], z) are classified by +/// atom_key_class(). +bool nv_is_motion(int cmdchar) +{ + int idx = find_command(cmdchar); + return idx >= 0 && (nv_cmds[idx].cmd_flags & NV_MOTION) != 0; +} + /// Search for a command in the commands table. /// /// @return -1 for invalid command. @@ -1085,15 +1094,6 @@ normal_end: static int normal_execute(VimState *state, int key) { - // Like most things in Vim, `toplevel` is a lie: exec_normal() happily sets it to true (inherited - // from Vim). So we track `depth` instead. - // - depth=0: No command executing (idle, between commands, sitting in vgetc()). - // - depth=1: Outermost command-frame. - // - depth=2: First nested frame (e.g. the "dd" inside ":normal! dd"). - // - depth=n: And so on... - static int depth = 0; - depth++; - CmdFrame frame; atom_cmd_start(&frame); @@ -1269,9 +1269,8 @@ static int normal_execute(VimState *state, int key) finish: normal_finish_command(s); - atom_cmd_end(&s->ca, &frame, depth == 1); + atom_cmd_end(&s->ca, &frame); xfree(s->ca.searchbuf); - depth--; return 1; } @@ -1563,14 +1562,14 @@ void restore_visual_mode(void) } } -/// Check for a balloon-eval special item to include when searching for an -/// identifier. When "dir" is BACKWARD "ptr[-1]" must be valid! +/// Check for a special item to include when searching for an identifier: "ptr->arg", +/// "list[idx]", "s.var". When `dir` is BACKWARD `ptr[-1]` must be valid! /// -/// @return true if the character at "*ptr" should be included. +/// @return true if the character at `*ptr` should be included. /// -/// @param dir the direction of searching, is either FORWARD or BACKWARD -/// @param *colp is in/decremented if "ptr[-dir]" should also be included. -/// @param bnp points to a counter for square brackets. +/// @param dir Direction of searching, either FORWARD or BACKWARD. +/// @param *colp Is in/decremented if "ptr[-dir]" should also be included. +/// @param bnp Points to a counter for square brackets. static bool find_is_eval_item(const char *const ptr, int *const colp, int *const bnp, const int dir) { // Accept everything inside []. @@ -1615,7 +1614,7 @@ static bool find_is_eval_item(const char *const ptr, int *const colp, int *const /// - FIND_IDENT: find an identifier (keyword) /// - FIND_STRING: find any non-white text /// - FIND_IDENT + FIND_STRING: find any non-white text, identifier preferred. -/// - FIND_EVAL: find text useful for C program debugging +/// - FIND_EVAL: also include "->", "[]" and "." () /// @param offset If not NULL, gets cursor position relative to start of `text`. /// @return Text length, or zero if no text is found. size_t find_ident_under_cursor(char **text, int find_type, int *offset) diff --git a/src/nvim/ops.c b/src/nvim/ops.c index 57d5d9630a..b9a3abc4c0 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -3687,10 +3687,8 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) case OP_FUNCTION: // Restore linebreak, so that when the user edits it looks as before. restore_lbr(lbr_saved); - atom_opfunc_slice(true); // Keys the opfunc reads interactively (getchar) are its payload. // call 'operatorfunc' op_function(oap); - atom_opfunc_slice(false); break; case OP_INSERT: diff --git a/test/functional/editor/atom_testutil.lua b/test/functional/editor/atom_testutil.lua index 95f849cc41..58bfc45e5e 100644 --- a/test/functional/editor/atom_testutil.lua +++ b/test/functional/editor/atom_testutil.lua @@ -78,6 +78,16 @@ m.minisurround_vim = [[ nnoremap ys MiniSurroundSetup() ]] +--- Minimal vim-sneak: :omap whose ":call" reads a 2-char getchar() and moves the cursor. +m.minisneak_vim = [[ + function! MiniSneak() abort + let c1 = nr2char(getchar()) + let c2 = nr2char(getchar()) + call search('\V' . c1 . c2, 'W') + endfunction + onoremap z :call MiniSneak() +]] + --- Minimal vim-surround "ds": a ":call" mapping whose edit runs through :normal inside a --- function, with a getchar() payload naming the surround to delete. m.delsurround_vim = [[ diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index dac1514751..bf3fe5e74c 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -52,6 +52,38 @@ describe('dot-repeat', function() feed('j0.') eq({ 'acbzacbonexy', 'czacbtwo' }, get_lines()) end) + + it('of a visual op does not churn showcmd', function() + local screen = Screen.new(40, 8, { ext_messages = true }) + command('set showcmd') + fn.setline(1, { 'foo bar', 'longword two' }) + feed('gg0viWgU') + feed('2gg0') + screen:expect({ any = '%^longword' }) + local updates = {} + function screen:_handle_msg_showcmd(msg) + local text = table.concat(vim.tbl_map(function(chunk) + return chunk[2] + end, msg)) + if text ~= '' then + updates[#updates + 1] = text + end + self.showcmd = msg + end + feed('.') + retry(nil, nil, function() + eq({ 'FOO bar', 'LONGWORD two' }, get_lines()) + end) + screen:sleep(50) + -- Stuffed (replayed) keys must not display in 'showcmd': ext_messages UIs redraw per showcmd + -- change, which would paint the replay's transient states (its in-progress Visual selection). + eq( + {}, + vim.tbl_filter(function(s) + return s:find('[UW]') ~= nil + end, updates) + ) + end) end) describe('CmdAtom', function() @@ -107,7 +139,7 @@ describe('CmdAtom', function() feed(']q') eq(3, fn.line('.')) -- the mapping did run (:cnext) eq( - { type = 'mapping', lhs = ']q', keys = '', changed = false }, + { type = 'mapping', lhs = ']q', changed = false }, pick(atom_last(), 'type', 'lhs', 'keys', 'changed') ) -- An empty-keys mapping that DOES edit still reports it: `changed` is @@ -118,7 +150,7 @@ describe('CmdAtom', function() end) ]]) feed(',e') - eq({ keys = '', lhs = ',e', changed = true }, pick(atom_last(), 'keys', 'lhs', 'changed')) + eq({ lhs = ',e', changed = true }, pick(atom_last(), 'keys', 'lhs', 'changed')) -- "" is opaque too, but unlike a Lua callback its command is text (like a ":" mapping). command('nnoremap ,c call setline(1, "N" . v:count)') @@ -138,6 +170,25 @@ describe('CmdAtom', function() n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(cmdev.keys)) eq('N3', fn.getline(1)) + -- mapping that returns a "lua …" (dot-repeat idiom #41387) captures the same + -- way: the constructed command is the atom. + n.exec_lua([[ + vim.keymap.set('n', ',x', function() + return 'call setline(1, "E" . v:count1)' + end, { expr = true }) + ]]) + feed('2,x') + cmdev = atom_last() + eq({ + type = 'excmd', + lhs = ',x', + keys = k('2call setline(1, "E" . v:count1)'), + count = 2, + }, pick(cmdev, 'type', 'lhs', 'keys', 'count')) + fn.setline(1, 'reset') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(cmdev.keys)) + eq('E2', fn.getline(1)) + -- Opaque key that changes nothing is invisible: mid-selection it must not void the pending -- visual atom, which would void its per-cursor extents. command('vnoremap ,n call execute("")') @@ -211,12 +262,11 @@ describe('CmdAtom', function() feed('vf,d') eq({ 'ef' }, get_lines()) eq({ '3dl', 'vf,d' }, atoms_tail(2)) - eq(3, atoms()[#atoms() - 1].count) -- Structured decomposition: operator/motion/operand as fields, no byte-parsing. local op = atoms()[#atoms() - 1] -- "3dl" eq( - { operator = 'd', cmd = 'l', changed = true }, - pick(op, 'operator', 'cmd', 'cmdarg', 'motionforce', 'changed') + { operator = 'd', cmd = 'l', count = 3, changed = true }, + pick(op, 'operator', 'cmd', 'cmdarg', 'count', 'motionforce', 'changed') ) -- A visual atom carries the completing operator's fields, and decomposes -- into its commands ("v", "f," and the operator). @@ -357,24 +407,15 @@ describe('CmdAtom', function() n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) eq({ ' bar', ' bar' }, get_lines()) - -- 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 = {} - for i = 1, 30 do - lines[i] = 'l' .. i - end + local lines = vim.tbl_map(function(i) + return 'l' .. i + end, fn.range(1, 30)) fn.setline(1, lines) feed('gg') local before = #atoms() feed('V') - n.poke_eventloop() eq(2, fn.line('.')) -- the scroll dragged the cursor: selection is lines 1-2 feed('d') -- Publishes with empty `CmdAtom.keys`; the keys that produced it are in `lhs`. @@ -489,13 +530,9 @@ describe('CmdAtom', function() ]]) --- Count of captured visual atoms. local function nvisual() - local count = 0 - for _, a in ipairs(atoms()) do - if a.type == 'visual' then - count = count + 1 - end - end - return count + return #vim.tbl_filter(function(a) + return a.type == 'visual' + end, atoms()) end fn.setline(1, { 'foo bar', 'longword bar' }) feed('gg0') @@ -562,18 +599,16 @@ describe('CmdAtom', function() it('one event per user action', function() n.clear({ args = { '--clean' }, args_rm = { '--cmd' } }) --- Feeds `keys`, asserts exactly ONE new event, with the given keys. - local function atom(keys, expected, lhs) + local function atom(keys, expected, lhs, type_) local before = #atoms() feed(keys) local evs = atoms() - eq({ before + 1, k(expected) }, { #evs, evs[#evs].keys }) - eq(k(lhs or expected), evs[#evs].lhs) + eq( + { before + 1, k(expected), k(lhs or expected), type_ or evs[#evs].type }, + { #evs, evs[#evs].keys, evs[#evs].lhs, evs[#evs].type } + ) end - local lines = {} - for i = 1, 20 do - lines[i] = 'alpha beta gamma delta epsilon zeta' - end - fn.setline(1, lines) + fn.setline(1, fn['repeat']({ 'alpha beta gamma delta epsilon zeta' }, 20)) feed('gg0') atoms_start() -- "." with nothing to repeat stuffs nothing. @@ -622,32 +657,22 @@ describe('CmdAtom', function() command('silent! nunmap %') -- the bundled matchit plugin maps it fn.setline(1, 'alpha (beta) gamma') feed('gg0f(') - atom('%', '%') - eq('motion', atom_last().type) + atom('%', '%', nil, 'motion') fn.setline(1, 'alpha beta gamma delta epsilon zeta') -- G/gg (absolute line), H/M/L (viewport) are motions: multicursor replay is meaningful? (but -- cursors may be "merged"). feed('gg0') - atom('G', 'G') - eq('motion', atom_last().type) - atom('gg', 'gg') - eq('motion', atom_last().type) - atom('L', 'L') - eq('motion', atom_last().type) - atom('H', 'H') - eq('motion', atom_last().type) - atom('M', 'M') - eq('motion', atom_last().type) + atom('G', 'G', nil, 'motion') + atom('gg', 'gg', nil, 'motion') + atom('L', 'L', nil, 'motion') + atom('H', 'H', nil, 'motion') + atom('M', 'M', nil, 'motion') -- Jumps: absolute/shared-state navigation, their own kind. - atom('ma', 'ma') - eq('normal', atom_last().type) -- "m" sets state; it does not jump - atom('`a', '`a') - eq('jump', atom_last().type) - atom('', '') - eq('jump', atom_last().type) + atom('ma', 'ma', nil, 'normal') -- "m" sets state; it does not jump + atom('`a', '`a', nil, 'jump') + atom('', '', nil, 'jump') -- Non-redoable commands: still emitted, as type "command". - atom('zz', 'zz') - eq('normal', atom_last().type) + atom('zz', 'zz', nil, 'normal') atom('u', 'u') atom('', '') -- "." emits its resolution (like "x" => "dl"). @@ -674,23 +699,19 @@ describe('CmdAtom', function() -- A nested cmdline opened by the command's own execution (":normal") -- does not hijack the payload. atom(':exe "normal! :echo 1\\r"', ':exe"normal!:echo1r"') - -- A mapping is ALWAYS an atom, even when its commands capture no - -- replayable keys ("]q" = :cnext): the event has empty keys. - fn.setqflist({ { text = 'one' }, { text = 'two' } }) - feed(']q') - eq( - { type = 'mapping', lhs = ']q', keys = '' }, - pick(atom_last(), 'type', 'lhs', 'keys', 'pending') - ) - -- A mapping that ends mid-operation says what it awaits. + -- A mapping that ends mid-operation captures its continuation. ",D" emits nothing until the + -- typed "w" finishes the operation: one atom captures both. command('nnoremap ,D d') command('nnoremap ,V v') + before = #atoms() feed(',D') - eq({ lhs = ',D', pending = 'operator' }, pick(atom_last(), 'lhs', 'pending')) - atom('w', 'dw') -- the supplied motion completes the operation + eq(before, #atoms()) + atom('w', 'dw', ',Dw') -- The supplied motion finishes the operation + -- Same for a mapping that opens Visual mode: "" abandons the selection and closes it. feed(',V') - eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending')) + eq(before + 1, #atoms()) feed('') + eq({ type = 'mapping', lhs = k(',V') }, pick(atom_last(), 'type', 'lhs', 'keys')) -- A mapping whose trailing prefix is completed by TYPED keys ("," + "w") -- ends where its own keys stop: each `lhs` owns only what it produced. command('set notimeout') @@ -701,9 +722,9 @@ describe('CmdAtom', function() feed(',xw') -- no poke between: nvim awaits the pending "," under 'notimeout' eq(before + 2, #atoms()) eq({ - { type = 'motion', lhs = ',x', keys = 'j', pending = 'mapping' }, + { type = 'motion', lhs = ',x', keys = 'j' }, { type = 'motion', lhs = ',w', keys = 'w' }, - }, atoms_tail(2, 'type', 'lhs', 'keys', 'pending')) + }, atoms_tail(2, 'type', 'lhs', 'keys')) command('set notimeout&') command('nunmap ,x') command('nunmap ,w') @@ -771,7 +792,7 @@ describe('CmdAtom', function() -- mapping: one event, keeping the command's own type and structure). local evs = atoms() eq(1, #evs) - -- Inapplicable fields (count/reg/arg/motionforce/text/pending/atoms here) are omitted. + -- Inapplicable fields (count/reg/arg/motionforce/text/atoms here) are omitted. eq({ type = 'operator', keys = 'dw', @@ -880,34 +901,75 @@ describe('CmdAtom', function() feed('gg0') atoms_start() feed('ysiw"') - -- The atom is the redobuff plus the getchar()'d payload: a replayed opfunc reads the same wrap - -- char. - -- Two atoms: the mapping ends mid-operation ("ys" => "g@"), then the operator the typed "iw" - -- completed. Only the mapping has a translated `lhs`; the operator is its own input. - eq({ - { type = 'mapping', keys = '', lhs = 'ys', pending = 'operator' }, - { type = 'operator', keys = 'g@iw"', lhs = 'g@iw"' }, - }, atoms_tail(2, 'type', 'keys', 'lhs', 'pending')) + -- One atom: the mapping captures the operation finished by "iw". `keys` is the redobuff plus + -- the getchar() payload: a replayed opfunc reads the same wrap char. + eq( + { type = 'operator', keys = 'g@iw"', lhs = 'ysiw"' }, + pick(atom_last(), 'type', 'keys', 'lhs') + ) eq({ '"alpha" beta' }, get_lines()) end) - it('a ":call" payload mapping publishes its resolved RHS', function() - -- The atom published to CmdAtom carries the RESOLVED RHS (the ":call" line). + it('":call" payload mapping appends payload to `keys`', function() n.exec(t_atom.delsurround_vim) fn.setline(1, { 'a (one)', 'b (two)' }) feed('gg0f(') atoms_start() feed('ds)') -- ")" is the getchar()'d payload eq({ 'a one', 'b (two)' }, get_lines()) - local ev = atoms()[#atoms()] - t.matches(':call DelSurround%(%)', ev.keys) - -- Replaying those keys would prompt for the payload again, so the atom asks - -- for LHS-replay instead: `lhs` carries the payload, `remap` says to remap. - eq({ lhs = 'ds)', remap = true }, pick(ev, 'lhs', 'remap')) + local ev = atom_last() + -- getchar() payload is appended to `keys`, replayable. + eq({ lhs = 'ds)', keys = ':call DelSurround()\n)' }, pick(ev, 'lhs', 'keys')) feed('2G0f(') - n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'm', false)]]):format(ev.lhs)) - n.poke_eventloop() + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) eq({ 'a one', 'b two' }, get_lines()) + + -- input() payload is appended to `keys`, replayable. + n.exec([[ + function! Suffix() abort + call setline('.', getline('.') .. input('suffix: ')) + endfunction + nnoremap ,s :call Suffix() + ]]) + feed('gg') + feed(',sX') + n.poke_eventloop() + eq('a oneX', fn.getline(1)) + ev = atom_last() + eq({ lhs = k(',sX'), keys = ':call Suffix()\nX\r' }, pick(ev, 'lhs', 'keys')) + feed('j') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) + eq('b twoX', fn.getline(2)) + + -- Operator-pending mapping (:omap custom motion, like vim-sneak "z") fully captured. + n.exec(t_atom.minisneak_vim) + api.nvim_buf_set_lines(0, 0, -1, true, { 'aa (x) here', 'bb (y) here' }) + feed('gg0') + n.poke_eventloop() + feed('dzhe') + n.poke_eventloop() + eq('here', fn.getline(1)) + ev = atom_last() + eq( + { type = 'operator', operator = 'd', lhs = 'dzhe', keys = 'd:call MiniSneak()\nhe' }, + pick(ev, 'type', 'operator', 'lhs', 'keys') + ) + feed('j0') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) + eq('here', fn.getline(2)) + + -- Burst input ("f(" and the mapping arrive together): "f" peeks for a composing char with + -- mappings enabled, so "ds" resolves while "f(" is still executing. The motion is still its + -- own atom, and the K_IGNORE left by the peek stays out of `lhs`. + api.nvim_buf_set_lines(0, 0, -1, true, { 'a (one)' }) + feed('gg0') + n.poke_eventloop() + feed('f(ds)') + eq({ 'a one' }, get_lines()) + eq({ + { type = 'motion', keys = 'f(', lhs = 'f(' }, + { type = 'excmd', keys = ':call DelSurround()\n)', lhs = 'ds)' }, + }, atoms_tail(2, 'type', 'keys', 'lhs')) end) it('|restore-undo-cursor|: `pos` + `undoseq` restore across every undo form', function() @@ -983,10 +1045,7 @@ describe('CmdAtom', function() _G.save = function() _G.saved = _G.last end _G.replay = function() local d = _G.saved - if not d.remap and d.keys == '' then - return -- Unreplayable Visual op: feeding `lhs` would be wrong. - end - vim.api.nvim_feedkeys(d.remap and d.lhs or d.keys, d.remap and 'm' or 'n', false) + vim.api.nvim_feedkeys(d.keys or d.lhs, d.keys and 'n' or 'm', false) end ]]) n.exec(t_atom.delsurround_vim) @@ -1015,11 +1074,11 @@ describe('CmdAtom', function() n.poke_eventloop() local l = get_lines() local d = n.exec_lua('return _G.saved') - local subs ---@type string? - for _, c in ipairs(d.atoms or {}) do - subs = (subs or '') .. c.keys - end - return { lhs = d.lhs, keys = d.keys, remap = d.remap, subs = subs, replayed = l[1] == l[2] } + local subs = d.atoms + and table.concat(vim.tbl_map(function(c) + return c.keys + end, d.atoms)) + return { lhs = d.lhs, keys = d.keys, subs = subs, replayed = l[1] == l[2] } end -- Asserts the table in runtime/doc/repeat.txt. @@ -1030,9 +1089,10 @@ describe('CmdAtom', function() { lhs = ',d', keys = 'dl', replayed = true }, { lhs = '@q', keys = 'dl', replayed = true }, { lhs = k(''), keys = 'dlw', subs = 'dlw', replayed = true }, - -- `keys` cannot replay these two, so the recipe feeds `lhs` with remapping. - { lhs = ']e', keys = '', remap = true, replayed = true }, - { lhs = 'ds)', keys = ':call DelSurround()\n', remap = true, replayed = true }, + -- No `keys` (Lua callback): the recipe feeds `lhs` with remapping. + { lhs = ']e', replayed = true }, + -- getchar() ")" is appended to `keys`, replayable. + { lhs = 'ds)', keys = ':call DelSurround()\n)', replayed = true }, }, { both('a one two', '', 'dw'), both('aaa bbb', '', 'x'), @@ -1058,34 +1118,75 @@ describe('CmdAtom', function() n.poke_eventloop() eq({ 4, 6 }, { fn.foldclosed(4), fn.foldclosedend(4) }) -- "zf" closes what it creates - -- Unreplayable: a void Visual op has no keys AND no `remap`, so the recipe - -- skips it rather than replaying a viewport-dependent selection. - local lines = {} - for i = 1, 30 do - lines[i] = 'l' .. i - end - api.nvim_buf_set_lines(0, 0, -1, true, lines) + -- Unreplayable: a void Visual op has empty keys, so the recipe feeds nothing + -- rather than replaying a viewport-dependent selection. + api.nvim_buf_set_lines( + 0, + 0, + -1, + true, + vim.tbl_map(function(i) + return 'l' .. i + end, fn.range(1, 30)) + ) feed('gg') feed(k('Vd')) n.poke_eventloop() n.exec_lua('_G.save()') - eq({ keys = '', remap = nil }, pick(n.exec_lua('return _G.saved'), 'keys', 'remap')) + eq({ keys = '' }, pick(n.exec_lua('return _G.saved'), 'keys')) local before = get_lines() n.exec_lua('_G.replay()') n.poke_eventloop() eq(before, get_lines()) - -- A repeat mapping is an atom too, and `remap` sends the recipe back to its - -- own `lhs`: a recorder must skip it, or the repeat replays itself. + -- A repeat mapping is an atom too, and absent `keys` sends the recipe back to + -- its own `lhs`: a recorder must skip it, or the repeat replays itself. n.exec_lua([[vim.keymap.set('n', '', function() end)]]) feed('') n.poke_eventloop() eq( - { type = 'mapping', keys = '', lhs = k(''), remap = true }, - pick(n.exec_lua('return _G.last'), 'type', 'keys', 'lhs', 'remap') + { type = 'mapping', lhs = k('') }, + pick(n.exec_lua('return _G.last'), 'type', 'keys', 'lhs') ) end) + it('"." mapping repeats the last edit atom, plugin operations included', function() + n.exec_lua([[ + local last ---@type vim.event.cmdatom.data? + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + if ev.data.changed and ev.data.lhs ~= '.' then + last = ev.data + end + end, + }) + vim.keymap.set('n', '.', function() + vim.schedule(function() + if last then + vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false) + end + end) + end) + ]]) + n.exec(t_atom.delsurround_vim) + fn.setline(1, { 'aa bb', 'a (one)', 'b (two)' }) + -- "Builtin" edit. + feed('gg0dw') + n.poke_eventloop() + feed('.') + retry(nil, 1000, function() + eq('', fn.getline(1)) + end) + -- Payload mapping: builtin "." could not repeat this (without e.g. vim-repeat). + feed('2G0f(ds)') + n.poke_eventloop() + eq('a one', fn.getline(2)) + feed('3G0f(.') + retry(nil, 1000, function() + eq('b two', fn.getline(3)) + end) + end) + it('"," repeats the last motion atom', function() -- Keep in sync with the example in runtime/doc/repeat.txt. n.exec_lua([[ @@ -1247,20 +1348,28 @@ describe('CmdAtom', function() ]]) atoms_start() - -- A plugin motion via "" is type="ex" (its RHS implementation), but the - -- observed effect classifies it: moved, and did not change the buffer. + -- A plugin motion via "" is type="excmd" (its RHS implementation); `moved` + -- reports the same observed effect as the builtin motion below it. feed('gg0]c') eq(3, fn.line('.')) eq( { type = 'excmd', moved = true, changed = false, pos = { 1, 0 } }, pick(atom_last(), 'type', 'moved', 'changed', 'pos') ) - -- Same observed effect as a builtin motion, which is the point. feed('gg0w') eq( { type = 'motion', moved = true, changed = false }, pick(atom_last(), 'type', 'moved', 'changed') ) + -- A motion that did not move is still a motion; `moved` is the separate question. + eq( + { { 'motion', false }, { 'motion', false }, { 'motion', false }, { 'normal', false } }, + vim.tbl_map(function(keys) + feed(keys) + local a = atom_last() + return { a.type, a.moved } + end, { 'G$w', 'gg0fz', 'gg0h', 'gg0zz' }) + ) -- Register-only operator also moves without changing: a motion carries no `operator`. feed('gg0wyb')