From 64236573525c257ecd7e268b255571328d4871c8 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Fri, 21 Aug 2026 13:42:17 -0400 Subject: [PATCH] feat(cmdatom)!: eliminate the need for vim-repeat #41414 Problem: - `lhs` is not fully realized. E.g. for a "payload" mapping `lhs` omits the `getchar()` payload during a mapping (vim-surround `ds'` reports `lhs="ds"`). This means plugins like vim-repeat are still needed... - The "delta" fields of a CmdAtom are calculated too late. - `` and `changed` check whatever (wrong) buffer a command (":bnext") might land in. - CTRL-W_w between two windows on the same buffer reports type="motion". Solution: - `CmdOrigin` samples (buf/win/cursor/changedtick) at each "scope" entry (CmdFrame, composite, Visual session, insert session). - `ddl` reports `changed=true` for the buffer it edited, regardless of where the cursor ends up. - New fields: - `pos`: cursor position at command start. - `moved`: indicates whether the cursor moved (in same buffer). - `undoseq`: undo state at settlement. - lhs now includes the payload: "ds)" reports lhs="ds)" instead of "ds". - Easy for users to "replay" any atom. - Rename: type "command" => "normal", "ex" => "excmd"; `arg` => `cmdarg` - Drop `cascade` field (no reason to expose it) --- runtime/doc/autocmd.txt | 104 +++---- runtime/doc/repeat.txt | 94 ++++++- runtime/lua/vim/_meta/events.lua | 13 +- src/nvim/input.c | 4 +- src/nvim/input_cmdatom.c | 192 +++++++++---- src/nvim/input_cmdatom.h | 4 +- src/nvim/input_cmdatom_defs.h | 20 +- src/nvim/input_defs.h | 4 +- src/nvim/normal.c | 2 +- test/functional/editor/cmdatom_spec.lua | 349 ++++++++++++++++++++++-- 10 files changed, 631 insertions(+), 155 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 63b368e76a..a2896463e7 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -421,83 +421,93 @@ ChanOpen Just after a channel was opened. *CmdAtom* CmdAtom After a user action (an "atom" of input): any - motion, operator, insert session, Visual-mode - keysequence, Ex cmdline (":cnext"), - mapping (and its subatoms), or scroll/mouse. + normal command, motion, operator, insert + session, Visual sequence, Ex cmdline + (":cnext"), mapping (and subatoms), macro, + or scroll/mouse. - Event is |deferred|. is the atom "type" (see below). + Event is |deferred|. Only for user input, not programmatic input: INPUT ATOM ~ - typed keys yes + Typed keys yes + Mapping, macro "@q" yes |nvim_input()| yes |nvim_feedkeys()| with "t" (see note) - dot-repeat |.| yes - mapping, typed "@q" yes + Hydrogen yes |:normal| no - API requests no, lol + API requests no "@q" fed by a script no |multicursor| cascade no - aborted operation no - terminal-mode keys no - mouse drag/release no - Hydrogen yes + Aborted operation no + Terminal-mode keys no + Mouse drag/release no (TODO?) Note: all keys fed during a mapping execution - (e.g. Lua callback) are part of the mapping's - expansion. + are part of the mapping's resolved `keys`. The |event-data| has these fields (type: `vim.event.cmdatom.data`): - - arg: Typed operand of `cmd`: the "x" of "fx", - the replacement char of |r|. - |key-notation|, like `cmd`. - - atoms: The constituent atoms of a composite - (multi-command mapping, Visual sequence), - in-order. A consumer of e.g. "motion" atoms - may want to inspect these "children". - - cascade: Queued for "multicursor" replay. - - changed: Buffer was changed. - - cmd: Command/motion/object name: "w", "fx" is - "f", "iw", "gJ", "", …. |key-notation| - (not raw bytes). - - count: Effective |count|; omitted if none. - - keys: Resolved (post-mapping) keysequence, - raw internal bytes (not |key-notation|): - feed directly to |feedkeys()| or - |nvim_feedkeys()| (mode "n") to replay. Use - |keytrans()| to key-notation. Empty if - not replayable: a Lua mapping, or - unreplayable Visual operation (mouse, |gv|, - Select mode). + - atoms: Ordered "subatoms" of a composite + atom (multi-command mapping, Visual sequence). + - changed: Buffer was changed by the atom. + - cmd: Command/motion/object name: "w", "f", + "iw", "gJ", "", …. |key-notation| + - cmdarg: Operand of `cmd`: the "x" of "fx", + the replacement char of |r|. |key-notation| + - count: Effective |count|, or nil. + - keys: Resolved (post-mapping) keysequence. + Raw internal bytes (use |keytrans()| to get + key-notation): feed to |nvim_feedkeys()| + (mode "n") to replay. Empty: unreplayable. - lhs: User input, before it is - resolved/translated. Mapping LHS, macro - register ("@q"), or the keys of a Visual op. - Raw bytes, like `keys`. + resolved/translated. Mapping LHS plus any + payload it read (|getchar()|), macro register + ("@q"), or Visual sequence. Raw bytes, like + `keys`. - motionforce |forced-motion|: "v", "V", or "" (|key-notation|). + - moved: Cursor moved. To detect a "motion": + `moved and not changed and not operator` - operator: Operator: "d", "g@", "zf", …. |key-notation|, like `cmd`. - - pending: What is awaiting input after - a mapping: "operator", "visual". + - 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. - Literal text, not key encoding. Examples: + Literal text, not key encoding. + Examples: - typed "iab" → text="ab" - typed ":cnext" → text="cnext" - typed "cnext" → text="cnext" - typed "iabc" → text="c" - - type: "command", "ex", "insert", "jump", - "mapping", "motion", "mouse", "operator", - "scroll", "visual". - - "command": neither edits nor moves the - cursor (undo, folds, CTRL-W commands, …). - - "jump": moves the cursor via + - type: + - "excmd" + - "insert" + - "jump": Moves the cursor via absolute/shared navigation state (|jumplist|, marks, |star|). + - "mapping" + - "motion" + - "mouse" + - "normal": Non-motion/jump command ("u", + CTRL-R, "za", CTRL-W, …). + - "operator" + - "scroll" + - "visual" + - undoseq: Undo state after the action + (|undotree()| `seq_cur`). Decreases on undo. *CmdlineChanged* CmdlineChanged After EVERY change inside command line. Also diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index 9660af5c1c..d3d7ddd612 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -11,7 +11,7 @@ Chapter 26 of the user manual introduces repeating |usr_26.txt|. Type |gO| to see the table of contents. ============================================================================== -Single repeats *single-repeat* +Single repeat *single-repeat* *.* . Repeat last change, with count replaced with [count]. @@ -37,30 +37,100 @@ jumps (mouse, |i_|, …) split an insert session. Repeating a Visual-mode command re-executes the captured keysequence, selection included. See |visual-repeat|. +============================================================================== +Semantic repeat *cmdatom* *action-repeat* + +The |CmdAtom| event is published on every user action. This avoids the need +for plugins to "announce" the repeatable unit, thus plugins like vim-repeat +aren't needed. + + *excalibur* +`CmdAtom.lhs` is the high-level user input collected during an action, +including getchar() input. This is signficant: it reflects the semantic +intent. `CmdAtom.keys` reveals the low-level internal commands that were +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"` + "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 + 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 + + 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) + end +< *motion-repeat* -Example: a "," mapping that repeats the last motion, like |;| but for any -motion ("3w", "fx", "/pat"): remember the last "motion" |CmdAtom| and -replay its keys: >lua - local last ---@type string? +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 + + local last ---@type vim.event.cmdatom.data? vim.api.nvim_create_autocmd('CmdAtom', { - pattern = 'motion', callback = function(ev) - last = ev.data.keys + if not ev.data.moved or ev.data.changed or ev.data.lhs == ',' then + return -- Skip non-motions, edits, and this mapping itself. + end + last = ev.data end, }) vim.keymap.set('n', ',', function() - -- CmdAtom is deferred: schedule the replay after any pending event, - -- so `last` is fresh when "," follows a motion. + -- CmdAtom is deferred: schedule the replay, in case "," follows a motion. vim.schedule(function() if last then - vim.api.nvim_feedkeys(last, 'n', false) -- "n": already resolved + replay(last) end end) end) < + *restore-undo-cursor* +Example: Restore cursor position after undo. Works for |u|, "3u", |CTRL-R|, +":undo N", |g-| and any mapping: >lua + + local seen = {} ---@type table + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + local seq = ev.data.undoseq + if not seq then + return + end + local s = seen[ev.buf] or {} + seen[ev.buf] = s + -- Note: g- :earlier may cross undo-tree branches, "best effort" in that case. + if s.prev and seq < s.prev and s[seq + 1] then + vim.api.nvim_win_set_cursor(0, s[seq + 1]) -- Undo: first abandoned state. + elseif s.prev and seq > s.prev and s[seq] then + vim.api.nvim_win_set_cursor(0, s[seq]) -- Redo: revisiting a known seq. + elseif ev.data.changed and not s[seq] then + s[seq] = ev.data.pos -- New edit. + end + s.prev = seq + end, + }) +< ============================================================================== -Multiple repeats *multi-repeat* +Multiple repeat *multi-repeat* *:g* *:global* *E148* :[range]g[lobal]/{pattern}/[cmd] @@ -128,7 +198,7 @@ This replaces all occurrences of "pat" with "PAT". The same can be done with: > Which is two characters shorter! ============================================================================== -Complex repeats *complex-repeat* +Complex repeat *complex-repeat* *q* *recording* *macro* q{0-9a-zA-Z"} Record typed characters into register {0-9a-zA-Z"} diff --git a/runtime/lua/vim/_meta/events.lua b/runtime/lua/vim/_meta/events.lua index ae2130d90e..fd18121c44 100644 --- a/runtime/lua/vim/_meta/events.lua +++ b/runtime/lua/vim/_meta/events.lua @@ -6,20 +6,23 @@ error('Cannot require a meta file') --- Data for the CmdAtom event. --- @class vim.event.cmdatom.data ---- @field arg? string Typed operand of `cmd` ("fx" => "x"). --- @field atoms? vim.event.cmdatom.data[] Subatoms of a composite (mapping, Visual sequence). ---- @field cascade boolean Queued for multicursor replay. --- @field changed boolean Changed the buffer. --- @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 Mapping LHS or macro register ("gj", "@q"). Raw bytes, like `keys`. +--- @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? 'operator'|'visual' Mapping ended mid-operation. +--- @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 'command'|'ex'|'insert'|'jump'|'mapping'|'motion'|'mouse'|'operator'|'scroll'|'visual' +--- @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. --- @class vim.event.lspattach.data --- @field client_id integer diff --git a/src/nvim/input.c b/src/nvim/input.c index 28b856dd1c..e84f81de18 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -260,8 +260,8 @@ static void redo_chars(const CmdSpec *spec, StringBuilder *buf, bool arg_meta) if (spec->cmd2 != NUL) { sb_add_char(buf, spec->cmd2); } - if (spec->arg != NUL && !arg_meta) { - sb_add_char(buf, spec->arg); + if (spec->cmdarg != NUL && !arg_meta) { + sb_add_char(buf, spec->cmdarg); } } diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 10a549fb8c..00b99afe2d 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -37,6 +37,7 @@ #include "nvim/state_defs.h" #include "nvim/strings.h" #include "nvim/vim_defs.h" +#include "nvim/window.h" #include "input_cmdatom.c.generated.h" @@ -69,7 +70,7 @@ static struct { char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting). bool queued; ///< A cascadable atom was queued (g_atoms) while collecting. bool macro; ///< Macro execution: captured as an "@x"-labeled atom. - varnumber_T tick; ///< b:changedtick at start. + CmdOrigin origin; ///< State at start. } composite; /// The executing command's frame; its `parent` chain spans nested `normal_execute()`. @@ -90,6 +91,7 @@ typedef enum { static struct { CmdAtomVec atoms; ///< Accumulated subatoms. A void session collects them as the `lhs` label. VatomState state; + CmdOrigin origin; ///< State at session start (before the "v"). } vatom; /// Per-command capture scratch. @@ -111,14 +113,14 @@ static struct { } typed; static const char *const type_names[] = { - [kACommand] = "command", - [kAEx] = "ex", - [kAInsert] = "insert", + [kAExcmd] = "excmd", [kAInsertSpan] = "insert", // spans display as "insert" (as a composite's `atoms`) + [kAInsert] = "insert", [kAJump] = "jump", [kAMapping] = "mapping", [kAMotion] = "motion", [kAMouse] = "mouse", + [kANormal] = "normal", [kAOperator] = "operator", [kAScroll] = "scroll", [kAVisual] = "visual", @@ -170,10 +172,39 @@ CmdSpec atom_cmd_spec(const cmdarg_T *cap) .count = cap->count0, .cmd = cap->cmdchar, .cmd2 = operand ? NUL : cap->nchar, - .arg = operand ? cap->nchar : NUL, + .cmdarg = operand ? cap->nchar : NUL, }; } +/// Gets the current buffer/window/cursor state. +static CmdOrigin atom_origin(void) +{ + CmdOrigin origin = { .win = curwin, .pos = curwin->w_cursor, + .tick = buf_get_changedtick(curbuf) }; + set_bufref(&origin.buf, curbuf); + return origin; +} + +/// True if the buffer was edited. False if the buf disappeared. +static bool atom_origin_changed(CmdOrigin origin) +{ + return bufref_valid(&origin.buf) && buf_get_changedtick(origin.buf.br_buf) != origin.tick; +} + +/// True if the cursor moved (in original buf). False if the win or buf disappeared. +static bool atom_origin_moved(CmdOrigin origin) +{ + return bufref_valid(&origin.buf) && win_valid(origin.win) + && origin.win->w_buffer == origin.buf.br_buf + && !equalpos(origin.pos, origin.win->w_cursor); +} + +/// Undo state of the buffer now, or 0 if the buffer disappeared. +static int atom_origin_undoseq(CmdOrigin origin) +{ + return bufref_valid(&origin.buf) ? origin.buf.br_buf->b_u_seq_cur : 0; +} + /// Composes a CmdSpec into `redo_keys` format. /// @return Allocated key sequence. static char *atom_redo_keys(CmdSpec spec) @@ -197,7 +228,7 @@ static CmdAtom atom_from_spec(CmdAtomType type, CmdSpec spec) } /// Gets a typed cmdline as a CmdAtom. -/// ":cnext" => CmdAtom{ kAEx, keys=":cnext", text="cnext" } +/// ":cnext" => CmdAtom{ kAExcmd, keys=":cnext", text="cnext" } static CmdAtom atom_from_cmdline(CmdAtomType type, cmdarg_T *ca, const char *cmdline) { StringBuilder sb = KV_INITIAL_VALUE; @@ -275,11 +306,11 @@ static Dict atom_dict(const CmdAtom *atom) } // Inapplicable fields are OMITTED, not defaulted. Dict d = ARRAY_DICT_INIT; - char *arg = atom_key_name(spec->arg); - if (arg != NULL && *arg != NUL) { - PUT(d, "arg", CSTR_AS_OBJ(arg)); + char *cmdarg = atom_key_name(spec->cmdarg); + if (cmdarg != NULL && *cmdarg != NUL) { + PUT(d, "cmdarg", CSTR_AS_OBJ(cmdarg)); } else { - xfree(arg); + xfree(cmdarg); } PUT(d, "changed", BOOLEAN_OBJ(atom->changed)); if (*cmd != NUL) { @@ -290,6 +321,15 @@ static Dict atom_dict(const CmdAtom *atom) if (spec->count > 0) { PUT(d, "count", INTEGER_OBJ(spec->count)); } + // Note: origin is undefined for an insert-session span, its cursor effect is undefined. + if (atom->origin.pos.lnum > 0) { + PUT(d, "moved", BOOLEAN_OBJ(atom->moved)); + Array pos = ARRAY_DICT_INIT; + ADD(pos, INTEGER_OBJ(atom->origin.pos.lnum)); + ADD(pos, INTEGER_OBJ(atom->origin.pos.col)); + PUT(d, "pos", ARRAY_OBJ(pos)); + PUT(d, "undoseq", INTEGER_OBJ(atom->undoseq)); + } // keys/lhs are RAW bytes (typeahead encoding). const char *keys = atom->keys != NULL ? atom->keys : ""; PUT(d, "keys", CSTR_TO_OBJ(keys)); @@ -305,6 +345,9 @@ 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)); } @@ -313,13 +356,12 @@ static Dict atom_dict(const CmdAtom *atom) } /// Schedules a CmdAtom event. -static void atom_emit(const CmdAtom *atom, const char *pending, bool cascade) +static void atom_emit(const CmdAtom *atom, const char *pending) { if (!has_event(EVENT_CMDATOM)) { return; } Dict data = atom_dict(atom); - PUT(data, "cascade", BOOLEAN_OBJ(cascade)); if (kv_size(atom->atoms) > 0) { Array atoms = ARRAY_DICT_INIT; for (size_t i = 0; i < kv_size(atom->atoms); i++) { @@ -330,7 +372,8 @@ static void atom_emit(const CmdAtom *atom, const char *pending, bool cascade) if (*pending != NUL) { PUT(data, "pending", CSTR_TO_OBJ(pending)); } - aucmd_defer(EVENT_CMDATOM, (char *)type_names[atom->type], NULL, AUGROUP_ALL, curbuf, NULL, + 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); } @@ -342,6 +385,12 @@ static void atom_emit(const CmdAtom *atom, const char *pending, bool cascade) void atom_push_raw(bool cascade, CmdAtom atom) { assert(atom.keys != NULL); + if (atom.origin.buf.br_buf != NULL) { + // Calculated here, from the atom's own baseline. + atom.changed = atom_origin_changed(atom.origin); + atom.moved = atom_origin_moved(atom.origin); + 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) { @@ -367,7 +416,7 @@ void atom_push_raw(bool cascade, CmdAtom atom) } else { if (atom.type != kAInsertSpan) { // Spans are cascade-internal; only emit the whole session (kAInsert). - atom_emit(&atom, "", cascade); + atom_emit(&atom, ""); } atom_free(&atom); } @@ -412,23 +461,29 @@ static void atom_stage_flush(CmdFrame *frame) if (frame->staged.keys == NULL) { return; } - frame->staged.changed = buf_get_changedtick(curbuf) != frame->tick; // Staged commands are edits, thus cascade. Except with no keys (poisoned Visual selection). bool cascade = *frame->staged.keys != NUL; atom_push(cascade, frame->staged); frame->staged = (CmdAtom){ 0 }; } -/// Queues an LHS-replay atom: a mapping that edited invisibly (:normal/:call, "ds'") re-runs -/// per cursor from its LHS + payload keys. Cascade only. -void atom_lhs_replay_queue(void) +/// The composite's trigger: its LHS plus the keys typed while it ran, i.e. the user "intention". +/// @return Allocated. +static char *atom_composite_lhs(void) { StringBuilder keys = KV_INITIAL_VALUE; kv_concat(keys, composite.lhs); kv_concat_len(keys, (char *)typed.keys.items + typed.map_start, kv_size(typed.keys) - typed.map_start); kv_push(keys, NUL); - kv_push(g_atoms, ((CmdAtom){ .type = kAMapping, .keys = keys.items, .remap = true })); + return keys.items; +} + +/// Queues an LHS-replay atom: a mapping that edited invisibly (:normal/:call, "ds'") re-runs +/// per cursor from its LHS + payload keys. Cascade only. +void atom_lhs_replay_queue(void) +{ + kv_push(g_atoms, ((CmdAtom){ .type = kAMapping, .keys = atom_composite_lhs(), .remap = true })); } /// True if the executing mapping queued a subatom: its edit was captured, no LHS-replay needed. @@ -449,7 +504,7 @@ static void atom_composite_start(const char *lhs, size_t len) xfree(composite.lhs); composite.lhs = xmemdupz(lhs, len); composite.queued = false; - composite.tick = buf_get_changedtick(curbuf); + composite.origin = atom_origin(); } /// Emits the composite atom with its collected subatoms (`CmdAtom.atoms`). @@ -462,26 +517,36 @@ static void atom_composite_end(const char *pending) if (composite.lhs == NULL) { return; } - char *lhs = composite.lhs; - composite.lhs = NULL; + // 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; + char *lhs = atom_composite_lhs(); + XFREE_CLEAR(composite.lhs); CmdAtom atom; if (kv_size(composite.atoms) == 0) { // The mapping's commands captured nothing (Ex/Lua commands, no-ops): but it is still a user // action, so emit it with empty keys (identified by `lhs`). - atom = (CmdAtom){ .type = kAMapping, .keys = xstrdup(""), .lhs = lhs, - .changed = buf_get_changedtick(curbuf) != composite.tick }; + atom = (CmdAtom){ .type = kAMapping, .keys = xstrdup(""), .lhs = lhs, .remap = remap, + .origin = composite.origin, + .changed = atom_origin_changed(composite.origin), + .moved = atom_origin_moved(composite.origin), + .undoseq = atom_origin_undoseq(composite.origin) }; } else if (kv_size(composite.atoms) == 1) { // Single subatom. "Unwrap" it so e.g. a motion mapping reports kAMotion, not kAMapping. atom = kv_pop(composite.atoms); xfree(atom.lhs); atom.lhs = lhs; + atom.remap = remap; } else { atom = (CmdAtom){ .type = kAMapping, .keys = atoms_concat_keys(composite.atoms).data, - .lhs = lhs, .changed = buf_get_changedtick(curbuf) != composite.tick }; + .lhs = lhs, .remap = remap, .origin = composite.origin, + .changed = atom_origin_changed(composite.origin), + .moved = atom_origin_moved(composite.origin), + .undoseq = atom_origin_undoseq(composite.origin) }; atom.atoms = composite.atoms; // Subatoms. composite.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // Reset. } - atom_emit(&atom, pending, composite.queued); + atom_emit(&atom, pending); atom_free(&atom); } @@ -729,6 +794,12 @@ void atom_map_start(const char *lhs, size_t len) || Visual.active) { return; } + if (atom_composite_active()) { + // 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_start(lhs, len); typed.map_start = kv_size(typed.keys); } @@ -774,6 +845,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa xfree(suffix); return false; } + const CmdOrigin origin = vatom.origin; // atom_visual_reset() clears the session. const bool prep = redoable && spec != NULL; if (suffix == NULL || !atom_visual_replayable()) { bool prepped = prep && spec->op != NUL && suffix != NULL; @@ -795,7 +867,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa atom_visual_reset(); // End the session before staging. if (emit) { atom_stage_set((CmdAtom){ .type = kAVisual, .spec = *spec, .keys = xstrdup(""), - .lhs = label }); + .lhs = label, .origin = origin }); } return prepped; } @@ -823,9 +895,11 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa // embedded in `keys` (and decomposed in `atoms`). .spec = spec != NULL ? *spec : (CmdSpec){ 0 }, .keys = keys, + .origin = origin, }; if (spec != NULL) { - kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix })); + kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix, + .origin = cur_frame->origin })); } else { xfree(suffix); } @@ -874,19 +948,21 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank) spec.motion_force = oap->motion_force; spec.cmd = cap->cmdchar; spec.cmd2 = operand ? NUL : cap->nchar; - spec.arg = operand ? cap->nchar : NUL; - atom_stage_set(atom_from_spec(kAOperator, spec)); + spec.cmdarg = operand ? cap->nchar : NUL; + CmdAtom op_atom = atom_from_spec(kAOperator, spec); + op_atom.origin = cur_frame->origin; + atom_stage_set(op_atom); } } else if (!Visual.active || oap->motion_force) { // Prepped: atom_cmd_end() derives the atom from redobuff. } else if (oap->op_type == OP_REPLACE && cap->nchar <= 0) { - // Visual "r": `spec.arg` cannot represent the sentinel nchar (REPLACE_CR_NCHAR), so + // Visual "r": `spec.cmdarg` cannot represent the sentinel (REPLACE_CR_NCHAR), so // hand-compose the literal suffix keys. char suffix[4] = { 'r', Ctrl_V, cap->nchar == REPLACE_CR_NCHAR ? CAR : NL, NUL }; atom_visual_end_suffix(xstrdup(suffix), &spec, redoable); } else { // Visual-mode op: complete the accumulated visual atom with the operator keys. - spec.arg = oap->op_type == OP_REPLACE ? cap->nchar : NUL; + spec.cmdarg = oap->op_type == OP_REPLACE ? cap->nchar : NUL; if ((oap->op_type == OP_NR_ADD || oap->op_type == OP_NR_SUB) && cap->arg) { // g: the "g" variant is distinguished by cap->arg, not the op char: compose it back. spec.op_extra = spec.op; @@ -971,7 +1047,10 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock) InsSession session = { .typed = atom_is_user_input(), // Sampled before the session. .vis = vis, - .tick = buf_get_changedtick(curbuf), + // A consumed selection opens the redo body, so the atom starts where the selection did. + // Else the CmdFrame origin, from before the entry moved the cursor (a/A/…). + .origin = vis == kVInsKeys ? vatom.origin + : cur_frame != NULL ? cur_frame->origin : atom_origin(), }; if (vis != kVInsNone && !mc_replaying()) { if (vis == kVInsKeys && !(atom_visual_replayable() && (vatom.state & kVatomTyped))) { @@ -981,6 +1060,10 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock) // The selection is consumed: already in the redo body. Also clears selection display. atom_visual_reset(); } + // bool repl = cmd == 'R' || cmd == 'V' || cmd == 'r' || cmd == 'v'; + // mc_ins_cascade_start(session.typed && count <= 1 && !repl + // && (vis == kVInsNone || (vis == kVInsKeys && !vblock)), + // session.origin.tick); return session; } @@ -1010,7 +1093,7 @@ static void atom_ins_push(const InsSession *session, bool cascade) return; } atom.text = get_last_insert_save(); - atom.changed = buf_get_changedtick(curbuf) != session->tick; + atom.origin = session->origin; atom_push_raw(cascade, atom); } @@ -1018,9 +1101,7 @@ static void atom_ins_push(const InsSession *session, bool cascade) /// 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->origin = atom_origin(); old->visual = Visual; old->keytyped = KeyTyped; old->captures = atom_captures; @@ -1059,9 +1140,9 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) // counts as no-op. Extend this (or atom_key_class()) when such a case is reported... bool opaque = (keycls & kKeyOpaque) != 0; bool synthetic = (keycls & kKeySynthetic) != 0; - bool unchanged = curbuf == old->buf - && equalpos(old->pos, curwin->w_cursor) - && buf_get_changedtick(curbuf) == old->tick + bool unchanged = curbuf == old->origin.buf.br_buf && curwin == old->origin.win + && !atom_origin_moved(old->origin) + && !atom_origin_changed(old->origin) && Visual.active == old->visual.active && (!Visual.active || (equalpos(old->visual.start, Visual.start) @@ -1075,11 +1156,9 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) if (mapped // NOT if cursor-global op: cascading (e.g. vim-repeat "nmap u") would undo at every cursor. && !curcmd.op_global - && (( - // NOT if it ended in another buffer: a navigation mapping ("nnoremap l") - // entering a buffer with cursors must not cascade. - curbuf == old->buf - && buf_get_changedtick(curbuf) != old->tick) || ins_cascaded)) { + // NOT if it ended in another buffer: a navigation mapping ("nnoremap l") + // entering a buffer with cursors must not cascade. + && ((curbuf == old->origin.buf.br_buf && atom_origin_changed(old->origin)) || ins_cascaded)) { map_edit = true; } @@ -1092,6 +1171,7 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) atom_visual_reset(); // Decided once, at session start. vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed; + vatom.origin = old->origin; } // Decided by the session (not atom_capturable()), so fed selections (":normal! vjd") still // accumulate for redo-prep. Recording/replay commands are meta (not part of the edit). @@ -1140,9 +1220,9 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) && ca->cmdchar != '"' && ca->cmdchar != '@' && ca->cmdchar != 'Q' && !scroll_cmd) || special_motion; - bool changed = buf_get_changedtick(curbuf) != old->tick; - bool motion = curbuf == old->buf - && !equalpos(old->pos, curwin->w_cursor) + 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 @@ -1170,7 +1250,7 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) // did neither is a no-op (vim-surround "ysa[" whose surround char was ). bool effect = changed || reg_max_ts(true) > old->reg_ts; if (atom.keys != NULL && *atom.keys != NUL) { - atom.changed = changed; + atom.origin = old->origin; atom_push(effect, atom); } else { atom_free(&atom); @@ -1179,13 +1259,13 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) && !(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.origin = old->origin; atom_push(false, atom); } 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; + CmdAtom atom = atom_from_cmdline(kAExcmd, ca, curcmd.cmdline); + atom.origin = old->origin; atom_push(false, atom); } else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) { // Non-redoable command (u, zz, q=): never cascaded as an edit. @@ -1194,8 +1274,8 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) // 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; + CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kANormal, spec); + atom.origin = old->origin; atom_push(follow, atom); } else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) { // Emit-only (viewport-dependent). @@ -1205,7 +1285,7 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) spec.count = 0; } CmdAtom atom = atom_from_spec(scroll_cmd ? kAScroll : kAMouse, spec); - atom.changed = changed; + atom.origin = old->origin; atom_push(false, atom); } if (vis && kv_size(vatom.atoms) == collected && !unchanged) { @@ -1213,7 +1293,7 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel) vatom.state |= kVatomVoid; } } - if (vis && (curbuf != old->buf || buf_get_changedtick(curbuf) != old->tick)) { + if (vis && (curbuf != old->origin.buf.br_buf || atom_origin_changed(old->origin))) { // Not replayable: edited buffer during selection, so the keys do not describe the change. vatom.state |= kVatomVoid; } diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index c6180a84e0..b8875163f6 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -16,9 +16,7 @@ extern CmdAtomVec g_atoms; /// 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 + CmdOrigin origin; ///< State at entry. VisualState visual; ///< Visual-mode state (active/start/mode are diffed). bool keytyped; ///< KeyTyped uint64_t captures; ///< Capture counter. diff --git a/src/nvim/input_cmdatom_defs.h b/src/nvim/input_cmdatom_defs.h index 6d6486bc00..71fd32ea1c 100644 --- a/src/nvim/input_cmdatom_defs.h +++ b/src/nvim/input_cmdatom_defs.h @@ -3,6 +3,7 @@ #include #include "klib/kvec.h" +#include "nvim/buffer_defs.h" #include "nvim/eval/typval_defs.h" #include "nvim/input_defs.h" @@ -14,9 +15,7 @@ // - replay, cascade, insert-cascade typedef enum CmdAtomType { - kACommand, ///< Not an (operator) edit, nor a cursor-move (u zz l …). Never cascades, - ///< except as part of a mapping's composite. - kAEx, ///< Ex command (":cnext"): the typed cmdline is the payload. + kAExcmd, ///< Ex command (":cnext"): the typed cmdline is the payload. kAInsert, ///< Insert session: entry command + text + . kAInsertSpan, ///< Span (chunk) of an ongoing insert-session, cascaded mid-session. kAJump, ///< Cursor movement by absolute/shared navigation (jumplist, marks, `*`): @@ -24,11 +23,21 @@ typedef enum CmdAtomType { kAMapping, ///< Subcommands of a mapping/macro, collapsed into one atom (`lhs`). kAMotion, ///< Motion (cascades in "q=" follow-motion mode). kAMouse, ///< Mouse action: emit-only (not replayable). + kANormal, ///< Normal-mode command that is not a motion or jump ("u", CTRL-R, "za", …). + ///< Never cascades, except as part of a mapping's composite. kAOperator, ///< Operator+motion, or a self-contained edit command. kAScroll, ///< Scroll (CTRL-Y/D/…, wheel): emit-only, like kAMouse. kAVisual, ///< Visual-mode sequence ("viwee" + operator). } CmdAtomType; +/// State gathered at start of a command, composite, or insert. For calculating the "delta" at end. +typedef struct { + bufref_T buf; ///< Buffer. + const win_T *win; ///< Window. + pos_T pos; ///< Cursor position. Stored here bc the window might be closed. + varnumber_T tick; ///< b:changedtick. +} CmdOrigin; + /// How an insert-session was entered from Visual mode. typedef enum { kVInsNone, ///< Not entered from Visual mode. @@ -41,7 +50,7 @@ typedef enum { typedef struct { bool typed; ///< Session is user input (typed, or via mapping/macro). VisualIns vis; ///< Session was entered from Visual mode. - varnumber_T tick; ///< b:changedtick at start (the session atom's `changed` baseline). + CmdOrigin origin; ///< State at start. } InsSession; typedef struct CmdAtom CmdAtom; @@ -57,8 +66,11 @@ struct CmdAtom { char *text; ///< Payload: insert-session text, or Ex/search cmdline. char *lhs; ///< Unresolved user input: mapping LHS or macro register ("@q"), or Visual op. ///< Label/hint, not replayed. NULL: untranslated, same as `keys`. + CmdOrigin origin; ///< Pre-command state. CmdAtomType type; + 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. }; diff --git a/src/nvim/input_defs.h b/src/nvim/input_defs.h index cbd0c8c6eb..4add8e8ebe 100644 --- a/src/nvim/input_defs.h +++ b/src/nvim/input_defs.h @@ -30,8 +30,8 @@ typedef struct { int op_extra; ///< Second operator char ("g~" => '~'; 0 = none) int motion_force; ///< Forced motion type ('v'/'V'/CTRL-V; 0 = none) int cmd; ///< Command/motion char ('J', 'p', 'f', K_LEFT, …; 0 = none) - int cmd2; ///< Second char of a two-char command name ("gJ" => 'J'; 0 = none) - int arg; ///< Operand ("fx" => 'x', "ma" => 'a'; 0 = none) + int cmd2; ///< Command char 2 (for two-char cmds: "gJ" => 'J'; 0 = none) + int cmdarg; ///< Command operand ("fx" => 'x', "ma" => 'a'; 0 = none) StringBuilder body; ///< Captured cmd "body": keys without the `["x][count]` "prefix", updated ///< as the cmd executes (redo_append_xx); redo_keys() prepends the prefix. ///< Empty in spec-only contexts (i.e. not a "redo" buf). diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 2f688042ed..5a617db00f 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -4585,7 +4585,7 @@ static void nv_replace(cmdarg_T *cap) invoke_edit(cap, true, 'r', false); } else { prep_redo(true, true, (CmdSpec){ .regname = cap->oap->regname, .count = cap->count1, - .cmd = 'r', .arg = cap->nchar }); + .cmd = 'r', .cmdarg = cap->nchar }); if (had_ctrl_v != NUL) { redo_append_char(had_ctrl_v); } diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index fe9ee77cc0..dac1514751 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -125,7 +125,7 @@ describe('CmdAtom', function() feed('3,c') local cmdev = atom_last() eq({ - type = 'ex', + type = 'excmd', lhs = ',c', keys = k('3call setline(1, "N" . v:count)'), text = 'call setline(1, "N" . v:count)', @@ -161,7 +161,7 @@ describe('CmdAtom', function() { type = 'motion', keys = 'w' }, { type = 'motion', keys = '3l' }, { type = 'motion', keys = k('/gamma') }, - { type = 'ex', keys = k(':nohlsearch') }, + { type = 'excmd', keys = k(':nohlsearch') }, }, atoms_tail(4, 'type', 'keys')) -- ":" embeds its count as the range prefill, never as composed digits; -- the count field carries it. @@ -169,7 +169,7 @@ describe('CmdAtom', function() feed('2:') eq(2, fn.line('.')) eq( - { type = 'ex', keys = k(':.,.+1'), count = 2, cmd = ':' }, + { type = 'excmd', keys = k(':.,.+1'), count = 2, cmd = ':' }, pick(atom_last(), 'type', 'keys', 'count', 'cmd') ) eq({ 'alpha beta', 'gamma delta' }, get_lines()) -- nothing was edited @@ -180,11 +180,11 @@ describe('CmdAtom', function() api.nvim_input_mouse('wheel', 'up', '', 0, 0, 0) api.nvim_input_mouse('left', 'press', '', 0, 1, 2) eq({ - { type = 'scroll', keys = k(''), cascade = false }, - { type = 'scroll', keys = k('3'), cascade = false }, - { type = 'scroll', keys = k(''), cascade = false }, - { type = 'mouse', keys = k(''), cascade = false }, - }, atoms_tail(4, 'type', 'keys', 'cascade')) + { type = 'scroll', keys = k('') }, + { type = 'scroll', keys = k('3') }, + { type = 'scroll', keys = k('') }, + { type = 'mouse', keys = k('') }, + }, atoms_tail(4, 'type', 'keys')) eq(2, fn.line('.')) -- the click moved the cursor -- /match is the atom type, never path-expanded. n.exec_lua([[ @@ -216,7 +216,7 @@ describe('CmdAtom', function() local op = atoms()[#atoms() - 1] -- "3dl" eq( { operator = 'd', cmd = 'l', changed = true }, - pick(op, 'operator', 'cmd', 'arg', 'motionforce', 'changed') + pick(op, 'operator', 'cmd', 'cmdarg', 'motionforce', 'changed') ) -- A visual atom carries the completing operator's fields, and decomposes -- into its commands ("v", "f," and the operator). @@ -225,11 +225,11 @@ describe('CmdAtom', function() eq( { { keys = 'v', cmd = 'v', changed = false }, - { keys = 'f,', cmd = 'f', arg = ',', changed = false }, + { keys = 'f,', cmd = 'f', cmdarg = ',', changed = false }, { keys = 'd', changed = true }, -- the completing operator did the edit }, vim.tbl_map(function(c) - return pick(c, 'keys', 'cmd', 'arg', 'changed') + return pick(c, 'keys', 'cmd', 'cmdarg', 'changed') end, vis.atoms) ) eq('d', vis.atoms[3].operator) @@ -249,12 +249,12 @@ describe('CmdAtom', function() fn.setline(1, { 'one', 'two' }) feed('gg0magJ') eq({ 'onetwo' }, get_lines()) -- "gJ": join without inserting a space - eq({ cmd = 'm', arg = 'a' }, pick(atoms()[#atoms() - 1], 'cmd', 'arg')) - eq({ cmd = 'gJ' }, pick(atom_last(), 'cmd', 'arg')) + eq({ cmd = 'm', cmdarg = 'a' }, pick(atoms()[#atoms() - 1], 'cmd', 'cmdarg')) + eq({ cmd = 'gJ' }, pick(atom_last(), 'cmd', 'cmdarg')) local nrec = #atoms() feed('qax') -- the recording register is an operand, not part of the name feed('q') - eq({ cmd = 'q', arg = 'a' }, pick(atoms()[nrec + 1], 'cmd', 'arg')) + eq({ cmd = 'q', cmdarg = 'a' }, pick(atoms()[nrec + 1], 'cmd', 'cmdarg')) -- Forced motion type ("dvj") is a field. fn.setline(1, { 'one', 'two' }) feed('gg0dvj') @@ -578,7 +578,7 @@ describe('CmdAtom', function() atoms_start() -- "." with nothing to repeat stuffs nothing. feed('.') - eq({ { type = 'command', keys = '.', lhs = '.' } }, atoms_tail(1, 'type', 'keys', 'lhs')) + eq({ { type = 'normal', keys = '.', lhs = '.' } }, atoms_tail(1, 'type', 'keys', 'lhs')) -- Operators: the atom is the redobuff (count/register included). -- "x" is normalized ("translated") to the elemental command "dl". atom('x', 'dl', 'x') @@ -640,14 +640,14 @@ describe('CmdAtom', function() eq('motion', atom_last().type) -- Jumps: absolute/shared-state navigation, their own kind. atom('ma', 'ma') - eq('command', atom_last().type) -- "m" sets state; it does not jump + 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) -- Non-redoable commands: still emitted, as type "command". atom('zz', 'zz') - eq('command', atom_last().type) + eq('normal', atom_last().type) atom('u', 'u') atom('', '') -- "." emits its resolution (like "x" => "dl"). @@ -670,7 +670,7 @@ describe('CmdAtom', function() atom('2/beta', '2/beta') -- Ex commands: their own atom kind, the cmdline is the "text" payload. atom(':set tw=42', ':settw=42') - eq({ type = 'ex', text = 'set tw=42' }, pick(atom_last(), 'type', 'text')) + eq({ type = 'excmd', text = 'set tw=42' }, pick(atom_last(), 'type', 'text')) -- A nested cmdline opened by the command's own execution (":normal") -- does not hijack the payload. atom(':exe "normal! :echo 1\\r"', ':exe"normal!:echo1r"') @@ -691,6 +691,22 @@ describe('CmdAtom', function() feed(',V') eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending')) feed('') + -- 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') + command('nmap ,x j,') + command('nnoremap ,w w') + feed('gg0') + before = #atoms() + 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 = ',w', keys = 'w' }, + }, atoms_tail(2, 'type', 'lhs', 'keys', 'pending')) + command('set notimeout&') + command('nunmap ,x') + command('nunmap ,w') -- An ABORTED mapping emits nothing: an error discards its remaining -- keys, and the composite with them. command('nnoremap ,E :NoSuchCmdx') @@ -739,7 +755,7 @@ describe('CmdAtom', function() feed('za') eq({ 'za' }, atoms_tail(1)) -- Neither an operator nor a motion: its own kind. - eq('command', atom_last().type) + eq('normal', atom_last().type) eq(-1, fn.foldclosed(1)) end) @@ -762,14 +778,18 @@ describe('CmdAtom', function() operator = 'd', cmd = 'w', changed = true, - cascade = true, -- the edit is cascadable lhs = ',d', + moved = false, -- "dw" deletes at the cursor: it does not move + pos = { 1, 0 }, -- cursor BEFORE the action, like nvim_win_get_cursor() + undoseq = 2, -- undo state AFTER it; decreases on undo (see |restore-undo-cursor|) }, evs[#evs]) -- Count and register are captured; one event per occurrence. feed('"z2dw') feed('"z2dw') evs = atoms() eq({ keys = '"z2dw', count = 2, reg = 'z' }, pick(evs[#evs], 'keys', 'count', 'reg')) + -- Identical occurrences, except `undoseq`: each edit is a new undo state. + evs[#evs].undoseq, evs[#evs - 1].undoseq = nil, nil eq(evs[#evs - 1], evs[#evs]) end) @@ -874,14 +894,196 @@ describe('CmdAtom', function() it('a ":call" payload mapping publishes its resolved RHS', function() -- The atom published to CmdAtom carries the RESOLVED RHS (the ":call" line). n.exec(t_atom.delsurround_vim) - fn.setline(1, { 'a (one)' }) + fn.setline(1, { 'a (one)', 'b (two)' }) feed('gg0f(') atoms_start() feed('ds)') -- ")" is the getchar()'d payload - eq({ 'a one' }, get_lines()) + eq({ 'a one', 'b (two)' }, get_lines()) local ev = atoms()[#atoms()] - eq('ds', ev.lhs) 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')) + feed('2G0f(') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'm', false)]]):format(ev.lhs)) + n.poke_eventloop() + eq({ 'a one', 'b two' }, get_lines()) + end) + + it('|restore-undo-cursor|: `pos` + `undoseq` restore across every undo form', function() + -- Keep in sync with the example in runtime/doc/repeat.txt. + n.exec_lua([[ + local seen = {} -- buf -> { prev = , [seq] = } + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + local seq = ev.data.undoseq + if not seq then + return + end + local s = seen[ev.buf] or {} + seen[ev.buf] = s + if s.prev and seq < s.prev and s[seq + 1] then + vim.api.nvim_win_set_cursor(0, s[seq + 1]) -- undid: first state left behind + elseif s.prev and seq > s.prev and s[seq] then + vim.api.nvim_win_set_cursor(0, s[seq]) -- redid: a seq we already have + elseif ev.data.changed and not s[seq] then + s[seq] = ev.data.pos -- new edit + end + s.prev = seq + end, + }) + ]]) + command('nnoremap u') + --- Puts the cursor at `pre`, edits, then undoes via `undo`. Reports the + --- column before the edit and after the undo: they must match. + local function undone(pre, edit, undo) + api.nvim_buf_set_lines(0, 0, -1, true, { 'this is a test' }) + n.poke_eventloop() + feed('gg0' .. pre) + n.poke_eventloop() + local before = fn.col('.') + feed(edit) + n.poke_eventloop() + -- Drained between steps: batched atoms would deliver the undo's restore + -- only after the redo already ran. + for _, step in ipairs(type(undo) == 'table' and undo or { undo }) do + if step:sub(1, 1) == ':' then + command(step:sub(2)) + else + feed(step) + end + n.poke_eventloop() + end + return { before, fn.col('.') } + end + -- "a"/"o" enter Insert AFTER moving the cursor, so the session's `pos` must + -- come from the command frame, not from where the entry command landed. + -- The last five need `undoseq`: they are not recognizable from the keys. + eq( + { { 4, 4 }, { 4, 4 }, { 4, 4 }, { 7, 7 }, { 4, 4 }, { 4, 4 }, { 4, 4 }, { 4, 4 }, { 4, 4 } }, + { + undone('lll', 'diw', 'u'), + undone('lll', 'aXY', 'u'), + undone('lll', 'oXY', 'u'), + undone('llllll', 'd^', 'u'), + undone('lll', 'diw', ''), -- mapping to undo + undone('lll', 'diwwdawdaw', '3u'), -- count + undone('lll', 'diwwdaw', ':undo 1'), + undone('lll', 'diw', 'g-'), + undone('lll', 'diw', { 'u', '0', '' }), -- redo, after moving away + } + ) + end) + + it('|atom-repeat|: one recipe replays every class of atom', function() + -- Keep in sync with the `replay()` example in runtime/doc/repeat.txt. + n.exec_lua([[ + _G.saved = nil + vim.api.nvim_create_autocmd('CmdAtom', { callback = function(ev) _G.last = ev.data end }) + _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) + end + ]]) + n.exec(t_atom.delsurround_vim) + command('nnoremap ,d x') + command('nnoremap xw') + command('let @q = "x"') + n.exec_lua([[ + vim.keymap.set('n', ']e', function() + vim.api.nvim_set_current_line(vim.api.nvim_get_current_line() .. '!') + end) + ]]) + + --- Runs `keys` on line 1, then replays that atom on line 2 (both prefixed by `pre`). Reports + --- the atom's input/resolution pair, whether the resolution decomposes into subatoms, and + --- whether the replay reproduced line 1. + local function both(line, pre, keys) + api.nvim_buf_set_lines(0, 0, -1, true, { line, line }) + feed('gg0' .. pre) + n.poke_eventloop() + feed(keys) + n.poke_eventloop() + n.exec_lua('_G.save()') + feed('2G0' .. pre) + n.poke_eventloop() + n.exec_lua('_G.replay()') + 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] } + end + + -- Asserts the table in runtime/doc/repeat.txt. + -- Note the symmetry: "x", a mapping to "x", and a macro of "x" all resolve to "dl". + eq({ + { lhs = 'dw', keys = 'dw', replayed = true }, + { lhs = 'x', keys = 'dl', replayed = true }, + { 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 }, + }, { + both('a one two', '', 'dw'), + both('aaa bbb', '', 'x'), + both('aaa bbb', '', ',d'), + both('aaa bbb', '', '@q'), + both('aaa bbb ccc', '', ''), + both('a b', '', ']e'), + both('a (one)', 'f(', 'ds)'), + }) + + -- Repeat fold command ("zf"). #9821 + api.nvim_buf_set_lines(0, 0, -1, true, { 'a {', ' x', '}', 'b {', ' y', '}' }) + feed('gg0zfa{') + n.poke_eventloop() + n.exec_lua('_G.save()') + eq( + { lhs = 'zfa{', keys = 'zfa{', changed = false, operator = 'zf' }, + pick(n.exec_lua('return _G.saved'), 'lhs', 'keys', 'changed', 'operator') + ) + feed('zR4gg0') -- open the new fold, move past it + eq(0, fn.foldlevel(4)) + n.exec_lua('_G.replay()') + 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) + 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')) + 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. + 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') + ) end) it('"," repeats the last motion atom', function() @@ -999,4 +1201,105 @@ describe('CmdAtom', function() eq(height + 2, fn.winheight(0)) wait_active(true) end) + + it('reports the original buffer', function() + n.exec_lua([[ + _G.evs = {} + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + table.insert(_G.evs, { buf = ev.buf, keys = ev.data.keys, changed = ev.data.changed }) + end, + }) + ]]) + command('let g:abufs = []') + command("autocmd CmdAtom * call add(g:abufs, expand('')->str2nr())") + local a = api.nvim_get_current_buf() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa bbb' }) + command('badd other') + local b = api.nvim_list_bufs()[2] + command('nnoremap ]b bprev') + + -- An edit reports its own buffer, and `changed`. + feed('gg0dw') + -- Leaving the buffer reports the buffer the command ran IN, not the one it + -- landed in. `changed` is about that same buffer: ":bnext" edits nothing. + feed(':bnext') + eq(b, api.nvim_get_current_buf()) + -- Same, from a mapping, in the other direction. + feed(']b') + eq(a, api.nvim_get_current_buf()) + local evs = n.exec_lua('return _G.evs') + eq({ + { buf = a, keys = 'dw', changed = true }, + { buf = a, keys = ':bnext\n', changed = false }, + { buf = b, keys = ('%sbprev\n'):format(k('')), changed = false }, + }, { evs[#evs - 2], evs[#evs - 1], evs[#evs] }) + -- agrees with the Lua callback's `buf`. + local abufs = n.eval('g:abufs') + eq({ a, a, b }, { abufs[#abufs - 2], abufs[#abufs - 1], abufs[#abufs] }) + end) + + it('reports `moved`/`pos` for the original buffer+window', function() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa bbb', 'ccc ddd', 'eee fff' }) + n.exec_lua([[ + _G.jump = function() vim.api.nvim_win_set_cursor(0, { 3, 0 }) end + vim.keymap.set('n', ']c', 'lua _G.jump()') + ]]) + 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. + 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') + ) + + -- Register-only operator also moves without changing: a motion carries no `operator`. + feed('gg0wyb') + eq( + { moved = true, changed = false, operator = 'y' }, + pick(atom_last(), 'moved', 'changed', 'operator') + ) + + -- Switching window is not a cursor-move, even on the SAME buffer: `pos` is per-window. + command('split') + feed('gg') + command('wincmd j') + feed('3G') + command('wincmd k') + feed('w') + eq(3, fn.line('.')) + eq({ type = 'normal', moved = false }, pick(atom_last(), 'type', 'moved')) + + -- Both are measured on the buffer/window the action STARTED in, so acting + -- and then navigating away still reports the effect. + command('nnoremap ]x normal! ddwincmd w') + command('nnoremap ]y normal! jwincmd w') + feed('gg]x') + eq({ changed = true, moved = false }, pick(atom_last(), 'changed', 'moved')) + command('wincmd w') + feed('gg]y') + eq({ changed = false, moved = true }, pick(atom_last(), 'changed', 'moved')) + + -- Switching buffer is not a cursor-move. + command('only') + local main = api.nvim_get_current_buf() + command('edit other') + api.nvim_buf_set_lines(0, 0, -1, true, { 'x', 'y', 'z' }) + feed('3G') + feed('') -- back to `main`, whose cursor is on another line + eq(main, api.nvim_get_current_buf()) + eq( + { type = 'normal', moved = false, pos = { 3, 0 } }, + pick(atom_last(), 'type', 'moved', 'pos') + ) + end) end)