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.
  - `<abuf>` 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).
- `dd<C-w>l` 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)
This commit is contained in:
Justin M. Keyes
2026-08-21 13:42:17 -04:00
committed by GitHub
parent 8e0e19c08b
commit 6423657352
10 changed files with 631 additions and 155 deletions

View File

@@ -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<CR>"),
mapping (and its subatoms), or scroll/mouse.
normal command, motion, operator, insert
session, Visual sequence, Ex cmdline
(":cnext<CR>"), mapping (and subatoms), macro,
or scroll/mouse.
Event is |deferred|.
<amatch> 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", "<Home>", …. |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", "<Home>", …. |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
"<C-V>" (|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<Esc>" → text="ab"
- typed ":cnext<CR>" → text="cnext"
- typed "<Cmd>cnext<CR>" → text="cnext"
- typed "iab<Left>c<Esc>" → 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

View File

@@ -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_<PageUp>|, …) 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"`
<F6> "dlw" Composite `:nnoremap <F6> xw`:
`type='mapping'`, subatoms "dl" and "w"
concat to the composite keys.
ds) ":call …<CR>" "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
V<C-E>d "" 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<CR>"): 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<CR>", "]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<integer, 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"}

View File

@@ -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'|'<C-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

View File

@@ -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);
}
}

View File

@@ -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<CR>" => CmdAtom{ kAEx, keys=":cnext<NL>", text="cnext" }
/// ":cnext<CR>" => CmdAtom{ kAExcmd, keys=":cnext<NL>", 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<C-V><CR>": `spec.arg` cannot represent the sentinel nchar (REPLACE_CR_NCHAR), so
// Visual "r<C-V><CR>": `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<C-A>: 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 <M-l> <C-w>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 <M-l> <C-w>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 <Esc>).
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<CR>"). 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<CR>" or "<Cmd>cnext<CR>". 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;
}

View File

@@ -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.

View File

@@ -3,6 +3,7 @@
#include <stdbool.h>
#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 <c-w>l …). Never cascades,
///< except as part of a mapping's composite.
kAEx, ///< Ex command (":cnext<CR>"): the typed cmdline is the payload.
kAExcmd, ///< Ex command (":cnext<CR>"): the typed cmdline is the payload.
kAInsert, ///< Insert session: entry command + text + <Esc>.
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.
};

View File

@@ -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).

View File

@@ -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);
}

View File

@@ -125,7 +125,7 @@ describe('CmdAtom', function()
feed('3,c')
local cmdev = atom_last()
eq({
type = 'ex',
type = 'excmd',
lhs = ',c',
keys = k('3<Cmd>call setline(1, "N" . v:count)<NL>'),
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<NL>') },
{ type = 'ex', keys = k(':nohlsearch<NL>') },
{ type = 'excmd', keys = k(':nohlsearch<NL>') },
}, 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:<CR>')
eq(2, fn.line('.'))
eq(
{ type = 'ex', keys = k(':.,.+1<NL>'), count = 2, cmd = ':' },
{ type = 'excmd', keys = k(':.,.+1<NL>'), 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('<C-E>'), cascade = false },
{ type = 'scroll', keys = k('3<C-Y>'), cascade = false },
{ type = 'scroll', keys = k('<ScrollWheelUp>'), cascade = false },
{ type = 'mouse', keys = k('<LeftMouse>'), cascade = false },
}, atoms_tail(4, 'type', 'keys', 'cascade'))
{ type = 'scroll', keys = k('<C-E>') },
{ type = 'scroll', keys = k('3<C-Y>') },
{ type = 'scroll', keys = k('<ScrollWheelUp>') },
{ type = 'mouse', keys = k('<LeftMouse>') },
}, atoms_tail(4, 'type', 'keys'))
eq(2, fn.line('.')) -- the click moved the cursor
-- <amatch>/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('<C-o>', '<C-O>')
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('<C-r>', '<C-R>')
-- "." emits its resolution (like "x" => "dl").
@@ -670,7 +670,7 @@ describe('CmdAtom', function()
atom('2/beta<CR>', '2/beta<NL>')
-- Ex commands: their own atom kind, the cmdline is the "text" payload.
atom(':set tw=42<CR>', ':set<Space>tw=42<NL>')
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"<CR>', ':exe<Space>"normal!<Space>:echo<Space>1<Bslash>r"<NL>')
@@ -691,6 +691,22 @@ describe('CmdAtom', function()
feed(',V')
eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending'))
feed('<Esc>')
-- 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 :NoSuchCmd<CR>x')
@@ -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>, [seq] = <cursor before that edit> }
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 <F8> 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<Esc>', 'u'),
undone('lll', 'oXY<Esc>', 'u'),
undone('llllll', 'd^', 'u'),
undone('lll', 'diw', '<F8>'), -- mapping to undo
undone('lll', 'diwwdawdaw', '3u'), -- count
undone('lll', 'diwwdaw', ':undo 1'),
undone('lll', 'diw', 'g-'),
undone('lll', 'diw', { 'u', '0', '<C-r>' }), -- 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 <F6> 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('<F6>'), 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', '', '<F6>'),
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('V<C-e>d'))
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', '<F7>', function() end)]])
feed('<F7>')
n.poke_eventloop()
eq(
{ type = 'mapping', keys = '', lhs = k('<F7>'), 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('<abuf>')->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 <Cmd>bprev<CR>')
-- 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<CR>')
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('<Cmd>')), changed = false },
}, { evs[#evs - 2], evs[#evs - 1], evs[#evs] })
-- <abuf> 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', '<Cmd>lua _G.jump()<CR>')
]])
atoms_start()
-- A plugin motion via "<Cmd>" 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('<C-w>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 <Cmd>normal! dd<CR><Cmd>wincmd w<CR>')
command('nnoremap ]y <Cmd>normal! j<CR><Cmd>wincmd w<CR>')
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('<C-^>') -- 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)