feat(cmdatom): mappings capture continuation

Problem:
A mapping that ends mid-operation (`nnoremap ,D d`) emits a content-free
"mapping" atom plus a `pending` field, and the "continuation" motion
arrives as a sibling atom. Consumers must stitch the two together (which
has broken cases, e.g. Insert-opening mappings (",i") lose their session
entirely).

- ",i": the session atom is dropped bc the mapping RHS is consumed
  before the session starts (typebuf_maplen()==0), so
  atom_is_user_input()=false.
- ":normal"-in-opfunc: the opfunc internal "v..y" session (a) became
  kVatomTyped just because the deferred composite was open, masking the
  real operator capture via atom_captures, and (b) its nested frames
  re-derived the outer redo.
- "Motion" based on `moved=true`, has false negatives.
- `CmdAtom.remap` is unnecessary, and clutters the docs/usage.

Solution:
- Introduce `frame_id` to identify CmdFrames.
- Classify `type=motion` better, via `NV_MOTION` flag on the `nv_cmds` table.
- Drop `CmdAtom.pending`, `CmdAtom.remap`.
- Defer atom_composite_end() at the clock edge while an operator is
  pending, Visual is active, or `restart_edit` is set: the composite
  keeps collecting, so the continuation is captured in the mapping atom.
- ",i": Now an open composite counts as user input.
- ":normal"-in-opfunc: Now handled correctly.
- `remap` is now decided by `composite.payload || 0 subatoms`.
  atom_payload_mark() records the read the resolution never captures.
- `toplevel` is now decided by `CmdFrame.parent == NULL`.

before/after:

    INPUT       BEFORE                             AFTER
    ---------------------------------------------------------------------
    ,D w        {mapping lhs=,D pending=operator}  {operator lhs=,Dw keys=dw}
                + {operator keys=dw}
    ysiw"       {mapping lhs=ys pending=operator}  {operator lhs=ysiw" keys=g@iw"}
                + {operator lhs=g@iw"}
    ,v d        {mapping pending=visual}           {visual lhs=,vd keys=viwd}
                + {visual lhs=viwd}
    ,i XY<Esc>  {normal keys=i lhs=,iXY<Esc>}      {insert keys=1iXY<Esc> text=XY}
This commit is contained in:
Justin M. Keyes
2026-08-21 20:04:24 +02:00
parent f583991553
commit 47cd769ed5
13 changed files with 532 additions and 303 deletions

View File

@@ -461,6 +461,7 @@ CmdAtom After a user action (an "atom" of input): any
Raw internal bytes (use |keytrans()| to get
key-notation): feed to |nvim_feedkeys()|
(mode "n") to replay. Empty: unreplayable.
Nil: lossy capture, replay `lhs` (mode "m").
- lhs: User input, before it is
resolved/translated. Mapping LHS plus any
payload it read (|getchar()|), macro register
@@ -468,21 +469,12 @@ CmdAtom After a user action (an "atom" of input): any
`keys`.
- motionforce |forced-motion|: "v", "V", or
"<C-V>" (|key-notation|).
- moved: Cursor moved. To detect a "motion":
`moved and not changed and not operator`
- moved: Cursor moved.
- operator: Operator: "d", "g@", "zf", ….
|key-notation|, like `cmd`.
- pending: The next atom completes what
this mapping started: "mapping",
"operator", "visual".
- pos: Start position: [row, col], 1-indexed
row, 0-indexed col.
- reg: Register name.
- remap: `keys` cannot replay this atom: the
mapping reads its own arguments, or resolved
to no keys. Re-run `lhs` instead. Empty
`keys` WITHOUT `remap` is unreplayable (a
Visual op with a tainted selection).
- text: Payload text: inserted text of an
insert session (after its last cursor-move,
like |quote.|), Ex cmdline, or search.
@@ -499,7 +491,9 @@ CmdAtom After a user action (an "atom" of input): any
absolute/shared navigation state
(|jumplist|, marks, |star|).
- "mapping"
- "motion"
- "motion": Non-jump motion. May emit
`moved=false` ("w" at buffer end, failed
"fx", …).
- "mouse"
- "normal": Non-motion/jump command ("u",
CTRL-R, "za", CTRL-W, …).

View File

@@ -567,6 +567,8 @@ CONCEPTS
cascade "dry-runs" spans to display the selection.
- VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse,
`gv`, a scroll that drags the cursor, …), thus not replayable.
- LOSSY: When capture lost part of a composite (payload with no capturing
atom, incomplete insert-session): `keys` cannot replay it, `lhs` can.
IMPLEMENTATION

View File

@@ -53,52 +53,69 @@ produced.
INPUT (`lhs`) RESOLUTION (`keys`) ~
dw "dw" Not translated.
zfa{ "zfa{" Not an edit (`changed=false`).
x "dl" Translated builtin
,d "dl" Mapping `:nnoremap ,d x`
@q "dl" Macro `@q = "x"`
x "dl" Translated builtin.
,d "dl" Mapping `:nnoremap ,d x`.
@q "dl" Macro `@q = "x"`.
<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
,Dw "dw" Incomplete mapping: ended mid-
operation (`:nnoremap ,D d`),
captures its continuation ("w").
ds) ":call …<NL>)" "Payload" mapping (e.g. vim-surround
"ds'" reads |getchar()|).
]q nil Lua mapping.
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
To repeat an atom, feed `keys` (mode "n"), or `lhs` (mode "m") if `keys=nil`. >lua
local function replay(a)
if not a.remap and a.keys == '' then
return -- Unreplayable Visual op.
end
vim.api.nvim_feedkeys(a.remap and a.lhs or a.keys, a.remap and 'm' or 'n', false)
vim.api.nvim_feedkeys(a.keys or a.lhs, a.keys and 'n' or 'm', false)
end
<
*motion-repeat*
Example: This "," mapping repeats the last "motion" ("zj", "3w", "fx",
"/pat<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
"/pat<CR>", "]c"). Drop the `pattern` to repeat any non-edit action, motion or
not ("zz", "zfa{", CTRL-W_w): >lua
local last ---@type vim.event.cmdatom.data?
vim.api.nvim_create_autocmd('CmdAtom', {
pattern = 'motion',
callback = function(ev)
if not ev.data.moved or ev.data.changed or ev.data.lhs == ',' then
return -- Skip non-motions, edits, and this mapping itself.
if ev.data.changed or ev.data.lhs == ',' then
return -- Skip edits, and this mapping itself.
end
last = ev.data
end,
})
vim.keymap.set('n', ',', function()
-- CmdAtom is deferred: schedule the replay, in case "," follows a motion.
-- CmdAtom is deferred; schedule the replay, in case "," follows a motion.
vim.schedule(function()
if last then
replay(last)
vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false)
end
end)
end)
<
*dot-repeat*
Example: This "." mapping repeats ANY edit, including operations provided by
plugins, without the need for "announcement" via vim-repeat or similar. >lua
local last ---@type vim.event.cmdatom.data?
vim.api.nvim_create_autocmd('CmdAtom', {
callback = function(ev)
if ev.data.changed and ev.data.lhs ~= '.' then
last = ev.data
end
end,
})
vim.keymap.set('n', '.', function()
-- CmdAtom is deferred; schedule the replay, in case "." follows an edit.
vim.schedule(function()
if last then
vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false)
end
end)
end)

View File

@@ -11,15 +11,13 @@ error('Cannot require a meta file')
--- @field cmd? string Command/motion/object name ("w", "f", "iw", "gJ").
--- @field cmdarg? string Operand of `cmd` ("fx" => "x").
--- @field count? integer Effective count.
--- @field keys string Resolved keysequence, raw bytes: feed to nvim_feedkeys() to replay.
--- @field lhs? string High-level user input: mapping LHS + any payload it read, or macro register ("gj", "ds'", "@q"). Raw bytes.
--- @field keys? string Resolved keysequence, raw bytes. Replay via `feedkeys(keys, 'n')`. Nil: lossy capture, replay via `feedkeys(lhs, 'm')` instead. Empty: unreplayable.
--- @field lhs string High-level user input: mapping LHS + any payload it read, or macro register ("gj", "ds'", "@q"). Raw bytes.
--- @field motionforce? 'v'|'V'|'<C-V>' forced-motion type.
--- @field moved? boolean Moved the cursor.
--- @field operator? string Operator name ("d", "g~", "g@"). key-notation.
--- @field pending? 'mapping'|'operator'|'visual' The next atom completes what this started.
--- @field pos? [integer,integer] Cursor before the action: 1-indexed row, 0-indexed column.
--- @field reg? string Register name.
--- @field remap? true `keys` cannot replay this (the mapping reads its own args): feed `lhs` with remapping.
--- @field text? string Inserted text, or the Ex/search cmdline.
--- @field type 'excmd'|'insert'|'jump'|'mapping'|'motion'|'mouse'|'normal'|'operator'|'scroll'|'visual'
--- @field undoseq? integer Undo state after the action (`undotree().seq_cur`). Decreases on undo.

View File

@@ -4712,8 +4712,10 @@ void get_user_input(const typval_T *const argvars, typval_T *const rettv, const
const int save_ex_normal_busy = ex_normal_busy;
ex_normal_busy = 0;
atom_payload_start();
rettv->vval.v_string = getcmdline_prompt(secret ? NUL : '@', p, get_echo_hl_id(),
xp_type, xp_arg, input_callback, false, NULL);
atom_payload_end();
ex_normal_busy = save_ex_normal_busy;
callback_free(&input_callback);

View File

@@ -1951,6 +1951,7 @@ static void getchar_common(typval_T *argvars, typval_T *rettv, bool allow_number
if (!simplify) {
no_reduce_keys++;
}
atom_payload_start();
while (true) {
if (cursor_flag == 'm' || (cursor_flag == NUL && msg_col > 0)) {
ui_cursor_goto(msg_row, msg_col);
@@ -1990,6 +1991,7 @@ static void getchar_common(typval_T *argvars, typval_T *rettv, bool allow_number
}
break;
}
atom_payload_end();
no_mapping--;
allow_keys--;
if (!simplify) {
@@ -2245,7 +2247,7 @@ static int char_iter(const uint8_t **itp, int nomap)
/// - When there is no match yet, return map_result_nomatch, need to get more
/// typeahead.
/// - On failure (out of memory) return map_result_fail.
static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth)
static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth, bool advance)
FUNC_ATTR_NONNULL_ARG(1)
{
mapblock_T *mp = NULL;
@@ -2495,7 +2497,7 @@ static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth)
(size_t)(keylen - typebuf.tb_maplen));
// A typed key sequence resolved this mapping (not a nested expansion:
// those keys come from another mapping): open its composite.
atom_map_start(mp->m_keys, (size_t)mp->m_keylen);
atom_map_start(mp->m_keys, (size_t)mp->m_keylen, !advance);
}
cmd_silent = (typebuf.tb_silent > 0);
@@ -2779,7 +2781,8 @@ static int vgetorpeek(bool advance)
break;
} else if (typebuf.tb_len > 0) {
// Check for a mapping in "typebuf".
map_result_T result = (map_result_T)handle_mapping(&keylen, &timedout, &mapdepth);
map_result_T result = (map_result_T)handle_mapping(&keylen, &timedout, &mapdepth,
advance);
if (result == map_result_retry) {
// try mapping again

View File

@@ -19,6 +19,7 @@
#include "nvim/autocmd.h"
#include "nvim/buffer.h"
#include "nvim/eval/typval_defs.h"
#include "nvim/eval/vars.h"
#include "nvim/ex_docmd.h"
#include "nvim/globals.h"
#include "nvim/input.h"
@@ -34,6 +35,7 @@
#include "nvim/ops.h"
#include "nvim/option_vars.h"
#include "nvim/register.h"
#include "nvim/state.h"
#include "nvim/state_defs.h"
#include "nvim/strings.h"
#include "nvim/vim_defs.h"
@@ -50,11 +52,19 @@ static void mc_vsel_refresh(void)
{
}
static void mc_vsel_clear(void)
{
}
static bool mc_following(void)
{
return false;
}
static void mc_clock_edge(bool map_edit)
{
}
CmdAtomVec g_atoms = KV_INITIAL_VALUE;
/// Capture clock: ticks on any kind of capture (atom push, Visual subatom). Used to answer "was
/// anything captured during this command (including its nested frames)?".
@@ -63,13 +73,19 @@ static uint64_t atom_captures = 0;
static bool atom_suppressed = false;
/// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions.
static bool map_edit = false;
/// Ticks per command frame (CmdFrame.id).
static uint64_t frame_id = 0;
/// Accumulating composite atom: an executing mapping/macro. See `vatom` for Visual composite.
static struct {
CmdAtomVec atoms; ///< Subatoms of the mapping/macro.
char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting).
bool queued; ///< A cascadable atom was queued (g_atoms) while collecting.
bool lossy; ///< Capture lost part of the mapping (incomplete insert, payload with no
///< capturing atom): `keys` cannot replay it, `lhs` can.
bool macro; ///< Macro execution: captured as an "@x"-labeled atom.
uint64_t frame; ///< CmdFrame already executing when a lookahead resolved this mapping
///< ("f(" + mapped key in one batch). 0: none.
CmdOrigin origin; ///< State at start.
} composite;
@@ -96,20 +112,18 @@ static struct {
/// Per-command capture scratch.
static struct {
bool redo_pending; ///< The command prepped the change atom (prep_redo*()).
uint64_t redo_frame; ///< CmdFrame that prepped (prep_redo*()); frame end captures it. 0: none.
char *cmdline; ///< The ":" payload captured at cmdline accept. NULL: none.
///< Note: search payloads ("/pat<CR>") travel on `cmdarg.searchbuf`.
bool ins_cascaded; ///< Did the command's insert-session already cascade?
bool op_global; ///< Already applied to every cursor (undo, "g CTRL-A"): must not cascade.
} curcmd;
/// Interactively typed keys of the executing command. Two slices mark keys read by getchar(), which
/// the redo body never sees: 'operatorfunc' input, and the mapping payload ("ds'" reads "'").
/// Interactively typed keys of the executing command. Collected during a composite (its `lhs`
/// suffix) and eval-read (the frame's payload slice, see atom_payload_start()).
static struct {
kvec_t(uint8_t) keys;
bool opfunc_active; ///< Collecting 'operatorfunc' input.
size_t opfunc_start, opfunc_end; ///< opfunc slice: keys[opfunc_start..opfunc_end)
size_t map_start; ///< mapping slice: keys[map_start..kv_size(keys))
size_t map_start; ///< This composite's slice: keys[map_start..kv_size(keys)), its lhs suffix.
} typed;
static const char *const type_names[] = {
@@ -332,7 +346,9 @@ static Dict atom_dict(const CmdAtom *atom)
}
// keys/lhs are RAW bytes (typeahead encoding).
const char *keys = atom->keys != NULL ? atom->keys : "";
PUT(d, "keys", CSTR_TO_OBJ(keys));
if (!atom->remap) {
PUT(d, "keys", CSTR_TO_OBJ(keys));
}
PUT(d, "lhs", CSTR_TO_OBJ(atom->lhs != NULL && *atom->lhs != NUL ? atom->lhs : keys));
if (*force != NUL) {
PUT(d, "motionforce", CSTR_TO_OBJ(force));
@@ -345,9 +361,6 @@ static Dict atom_dict(const CmdAtom *atom)
if (spec->regname != 0) {
PUT(d, "reg", CSTR_TO_OBJ(regname));
}
if (atom->remap) {
PUT(d, "remap", BOOLEAN_OBJ(true));
}
if (atom->text != NULL && *atom->text != NUL) {
PUT(d, "text", CSTR_TO_OBJ(atom->text));
}
@@ -356,7 +369,7 @@ static Dict atom_dict(const CmdAtom *atom)
}
/// Schedules a CmdAtom event.
static void atom_emit(const CmdAtom *atom, const char *pending)
static void atom_emit(const CmdAtom *atom)
{
if (!has_event(EVENT_CMDATOM)) {
return;
@@ -369,19 +382,18 @@ static void atom_emit(const CmdAtom *atom, const char *pending)
}
PUT(data, "atoms", ARRAY_OBJ(atoms));
}
if (*pending != NUL) {
PUT(data, "pending", CSTR_TO_OBJ(pending));
}
buf_T *buf = atom->origin.buf.br_buf != NULL ? atom->origin.buf.br_buf : curbuf;
aucmd_defer(EVENT_CMDATOM, (char *)type_names[atom->type], NULL, AUGROUP_ALL, buf, NULL,
&DICT_OBJ(data));
api_free_dict(data);
}
/// Emits a CmdAtom event, or collects it as a subatom of a composite. If `cascade` is true, queues
/// a copy for mcursor cascade.
/// Emits a CmdAtom event, or collects it as a subatom of an open scope. If `cascade` is true,
/// queues a copy for mcursor cascade. Takes ownership of the atom's allocated members.
///
/// Takes ownership of the atom's allocated members. Caller sets `atom.changed`.
/// Both scopes (composite, Visual session) can be open. Routed by scope kind, not depth:
/// a collecting Visual session takes the atom, its atom then lands in the composite (",v"+"d").
/// Depth would invert "v@q", where the "@q" composite opened LAST yet collects nothing.
void atom_push_raw(bool cascade, CmdAtom atom)
{
assert(atom.keys != NULL);
@@ -392,7 +404,6 @@ void atom_push_raw(bool cascade, CmdAtom atom)
atom.undoseq = atom_origin_undoseq(atom.origin);
}
if (atom_visual_pending()) {
// Collecting the Visual composite: subatom of the pending visual atom.
if (vatom.state & kVatomTyped) {
// Not for kVatomFed: redo-prep must not mark the enclosing span as captured.
atom_captures++;
@@ -408,15 +419,27 @@ void atom_push_raw(bool cascade, CmdAtom atom)
last->changed = atom.changed;
}
}
if (cascade && composite.lhs != NULL) {
composite.queued = true;
// `composite.frame`: the command that was executing when a peek opened the composite is not part
// of it.
const bool collect = composite.lhs != NULL
&& (cur_frame == NULL || cur_frame->id != composite.frame);
if (cascade) {
CmdAtom copy = atom;
copy.keys = xstrdup(atom.keys);
copy.text = NULL; // replay (mc_execute()) reads only type/keys/remap: not the text,
copy.lhs = NULL; // nor the label,
copy.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // nor the decomposition
kv_push(g_atoms, copy);
if (collect) {
composite.queued = true;
}
}
if (composite.lhs != NULL) {
if (collect) {
kv_push(composite.atoms, atom);
} else {
if (atom.type != kAInsertSpan) {
// Spans are cascade-internal; only emit the whole session (kAInsert).
atom_emit(&atom, "");
atom_emit(&atom);
}
atom_free(&atom);
}
@@ -504,6 +527,8 @@ static void atom_composite_start(const char *lhs, size_t len)
xfree(composite.lhs);
composite.lhs = xmemdupz(lhs, len);
composite.queued = false;
composite.lossy = false;
composite.frame = 0;
composite.origin = atom_origin();
}
@@ -511,15 +536,14 @@ static void atom_composite_start(const char *lhs, size_t len)
///
/// :nnoremap gj i<C-J><Esc>k$
/// "gj" => CmdAtom{ .lhs="gj", .keys="1i<NL><Esc>k$", kAMapping }
static void atom_composite_end(const char *pending)
static void atom_composite_end(void)
{
composite.macro = false; // "@x" capture ends with its composite.
if (composite.lhs == NULL) {
return;
}
// The mapping read a payload via getchar() ("ds'"); replaying `keys` would prompt again.
// Consumer should rerun `lhs` (which carries the payload) in "remap" mode.
const bool remap = kv_size(typed.keys) > typed.map_start || kv_size(composite.atoms) == 0;
// LHS-replay when the capture is lossy, or captured nothing (Ex/Lua edits).
const bool remap = composite.lossy || kv_size(composite.atoms) == 0;
char *lhs = atom_composite_lhs();
XFREE_CLEAR(composite.lhs);
CmdAtom atom;
@@ -546,7 +570,7 @@ static void atom_composite_end(const char *pending)
atom.atoms = composite.atoms; // Subatoms.
composite.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // Reset.
}
atom_emit(&atom, pending);
atom_emit(&atom);
atom_free(&atom);
}
@@ -571,7 +595,9 @@ bool atom_is_user_cmd(void)
/// typed "i", mapped "gj" => true; "." (stuffed redo), "@r" => false
static bool atom_is_user_input(void)
{
return KeyTyped || (atom_is_user_cmd() && typebuf_maplen() > 0);
// An open composite is user input even after its keys were consumed (":nnoremap ,i i").
return KeyTyped
|| (atom_is_user_cmd() && (typebuf_maplen() > 0 || atom_composite_active()));
}
/// Suppresses atom pushes. For internal operators.
@@ -635,11 +661,20 @@ unsigned atom_key_class(int cmd, int arg)
return kKeyJump;
case Ctrl_T:
return kKeyJump | kKeyInsFlush;
// Multiplexed: one nv_cmds entry => many commands. NV_MOTION cannot tag them; char 2 decides.
case 'g':
return (arg == ';' || arg == ',') ? kKeyJump : 0;
if (arg == ';' || arg == ',') {
return kKeyJump;
}
return strchr("gjk0^$_meEoM", arg) != NULL ? kKeyMotion : 0;
case '[':
case ']':
return arg == 'C' ? kKeyJump : 0; // "]C"/"[C": jump to the next/previous cursor
if (arg == 'C') {
return kKeyJump; // "]C"/"[C": jump to the next/previous cursor
}
return strchr("[](){}mMcsz#*/", arg) != NULL ? kKeyMotion : 0;
case 'z':
return (arg == 'j' || arg == 'k') ? kKeyMotion : 0;
case '*':
case '#':
case '\'':
@@ -691,22 +726,53 @@ void atom_cmdline_set(int firstc, const char *line, size_t len)
curcmd.cmdline = xmemdupz(line, len);
}
/// Opens/closes the 'operatorfunc' slice of the typed-key stream.
void atom_opfunc_slice(bool active)
/// True while collecting typed keys: during a composite (for `lhs`), or a CmdFrame's payload slice.
static bool atom_typed_collecting(void)
{
typed.opfunc_active = active;
if (active) {
typed.opfunc_start = kv_size(typed.keys);
return atom_composite_active()
|| (cur_frame != NULL && cur_frame->payload_start != SIZE_MAX);
}
/// Opens the CmdFrame's payload slice: keys from getchar(), input() append to `CmdAtom.keys`.
void atom_payload_start(void)
{
if (cur_frame != NULL && cur_frame->payload_start == SIZE_MAX && !mc_replaying()) {
cur_frame->payload_start = cur_frame->payload_end = kv_size(typed.keys);
}
typed.opfunc_end = kv_size(typed.keys);
}
/// Closes (or extends) the payload slice.
void atom_payload_end(void)
{
if (cur_frame != NULL && cur_frame->payload_start != SIZE_MAX && !mc_replaying()) {
cur_frame->payload_end = kv_size(typed.keys);
}
}
/// Drains the CmdFrame's payload slice to `CmdAtom.keys`.
static void atom_payload_append(CmdAtom *atom, CmdFrame *frame)
{
size_t plen = frame->payload_start == SIZE_MAX ? 0 : frame->payload_end - frame->payload_start;
if (plen == 0 || atom->keys == NULL) {
return;
}
size_t klen = strlen(atom->keys);
atom->keys = xrealloc(atom->keys, klen + plen + 1);
memcpy(atom->keys + klen, typed.keys.items + frame->payload_start, plen);
atom->keys[klen + plen] = NUL;
frame->payload_start = SIZE_MAX;
}
/// Collects a typed key (gotchars()) into the stream.
void atom_typed_add(const uint8_t *chars, size_t len)
{
if (!typed.opfunc_active && !atom_composite_active()) {
if (mc_replaying() || !atom_typed_collecting()) {
return;
}
if (len == 3 && chars[0] == K_SPECIAL
&& (atom_key_class(TERMCAP2KEY(chars[1], chars[2]), NUL) & kKeySynthetic)) {
return; // Not user input: K_IGNORE from a mapping resolved during peek/K_EVENT/…
}
for (size_t i = 0; i < len; i++) {
kv_push(typed.keys, chars[i]);
}
@@ -716,7 +782,7 @@ void atom_typed_add(const uint8_t *chars, size_t len)
/// into typeahead and will be collected again (ungetchars()).
void atom_typed_del(size_t len)
{
if (!typed.opfunc_active && !atom_composite_active()) {
if (mc_replaying() || !atom_typed_collecting()) {
return;
}
kv_size(typed.keys) -= MIN(len, kv_size(typed.keys));
@@ -729,16 +795,18 @@ static void atom_redo_reset(void)
if (!atom_is_user_cmd()) {
return;
}
curcmd.redo_pending = false;
curcmd.redo_frame = 0;
curcmd.ins_cascaded = false;
XFREE_CLEAR(curcmd.cmdline);
// The opfunc slice resets per command; the stream itself is truncated only once the
// mapping slice ends too (with its composite).
// The stream is truncated only once the mapping slice ends too (with its composite).
if (!atom_composite_active()) {
kv_size(typed.keys) = 0;
typed.map_start = 0;
// Truncation voids the payload slice.
for (CmdFrame *frame = cur_frame; frame != NULL; frame = frame->parent) {
frame->payload_start = SIZE_MAX;
}
}
typed.opfunc_start = typed.opfunc_end = kv_size(typed.keys);
}
/// Marks the running command as already applying to every cursor (see `curcmd.op_global`).
@@ -749,15 +817,15 @@ void atom_op_global_set(void)
}
/// Claims the prepped redo as the command's atom (prep_redo()). Only toplevel user commands (a
/// nested redo-prep is not an atom). Declines Ex/Lua operators.
/// nested redo-prep is not an atom). Declines Lua operators.
void atom_redo_set(CmdSpec spec)
{
if (spec.cmd == ':' || spec.cmd == K_COMMAND || spec.cmd == K_LUA) {
if (spec.cmd == K_LUA) {
atom_redo_reset();
return;
}
if (atom_is_user_cmd()) {
curcmd.redo_pending = true;
curcmd.redo_frame = cur_frame != NULL ? cur_frame->id : 0;
}
}
@@ -787,7 +855,9 @@ void atom_stuff_start(const cmdarg_T *cap)
}
/// Starts accumulating a composite for a mapping resolved from typed keys (vgetorpeek()).
void atom_map_start(const char *lhs, size_t len)
///
/// @param peeked Resolved by a peek: the executing command did not consume the mapping's keys.
void atom_map_start(const char *lhs, size_t len, bool peeked)
{
if (!atom_has_consumers()
|| reg_executing != 0 || ex_normal_busy != 0 || !(State & MODE_NORMAL)
@@ -798,9 +868,22 @@ void atom_map_start(const char *lhs, size_t len)
// Mapping resolved from another's trailing prefix ("nmap x j," + "nnoremap ,w w"): end the
// pending composite, so each `lhs` owns only the keys it produced.
typed.map_start = kv_size(typed.keys);
atom_composite_end("mapping");
atom_composite_end();
}
const char *op = get_vim_var_str(VV_OP); // v:operator
if (get_real_state() == MODE_OP_PENDING && *op != NUL) {
// Op-pending mapping (:omap) continues the operator (vim-sneak "dz(b").
assert(lhs[len] == NUL);
char *full = concat_str(op, lhs);
atom_composite_start(full, strlen(full));
xfree(full);
typed.map_start = kv_size(typed.keys);
return;
}
atom_composite_start(lhs, len);
if (peeked && cur_frame != NULL) {
composite.frame = cur_frame->id;
}
typed.map_start = kv_size(typed.keys);
}
@@ -809,6 +892,8 @@ static void atom_visual_reset(void)
{
vatom.state = kVatomNone;
atoms_free(&vatom.atoms);
vatom.origin = (CmdOrigin){ 0 };
mc_vsel_clear();
}
/// Visual atom is pending. A void session still accumulates, for the `lhs` label.
@@ -1076,6 +1161,10 @@ void atom_ins_end(const InsSession *session, bool busy)
bool visual = session->vis != kVInsNone;
if (!session->typed || busy || restart_edit != 0 || !atom_buf_has_consumers()
|| (visual && session->vis != kVInsKeys)) {
if (session->typed && (busy || restart_edit != 0) && atom_composite_active()) {
// Incomplete session (i_CTRL-O): its resolution is never captured.
composite.lossy = true;
}
return;
}
atom_ins_push(session, true);
@@ -1105,12 +1194,15 @@ void atom_cmd_start(CmdFrame *old)
old->visual = Visual;
old->keytyped = KeyTyped;
old->captures = atom_captures;
old->id = ++frame_id;
// Sampled: "q=" toggled DURING a command must not apply to it retroactively.
old->follow = false;
old->consumers = atom_buf_has_consumers();
// Diffed at command end: detects a register-write (yank).
old->reg_ts = old->consumers ? reg_max_ts(true) : 0;
old->staged = (CmdAtom){ 0 };
old->payload_start = SIZE_MAX;
old->payload_end = 0;
old->parent = cur_frame;
cur_frame = old;
curcmd.op_global = false;
@@ -1122,7 +1214,7 @@ void atom_cmd_start(CmdFrame *old)
///
/// Skipped for a command that stuffed keys ("x" stuffs "dl": its resolution is the atom), or that
/// already captured its own atom (do_pending_operator(), insert spans).
static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
{
if (mc_replaying() || atom_suppressed) {
return;
@@ -1170,7 +1262,8 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
if (!old->visual.active) {
atom_visual_reset();
// Decided once, at session start.
vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed;
vatom.state = (old->keytyped || (atom_composite_active() && atom_is_user_cmd()))
? kVatomTyped : kVatomFed;
vatom.origin = old->origin;
}
// Decided by the session (not atom_capturable()), so fed selections (":normal! vjd") still
@@ -1221,31 +1314,21 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
&& !scroll_cmd)
|| special_motion;
bool changed = atom_origin_changed(old->origin);
bool moved = atom_origin_moved(old->origin);
bool motion = moved
&& !changed
&& !finish_op && !jump_cmd
&& ((ca->cmdchar > 0 && ca->cmdchar < 0x100
&& strchr("/?:!Qq", ca->cmdchar) == NULL)
|| special_motion);
// Note: an operator's motion belongs to the operator (`finish_op`).
bool motion = (nv_is_motion(ca->cmdchar) || special_motion) && !changed
&& !finish_op && !jump_cmd;
// Mapping-internal motions are part of its recipe: queue them, the clock edge decides.
bool follow = (mc_following() || mapped) && motion;
//
// Route: decide the atom type and push it.
//
if (curcmd.redo_pending && !mouse_cmd) {
if (curcmd.redo_frame == old->id && !mouse_cmd) {
// Not for mouse commands (middle-click paste): pasting at every cursor would use
// viewport-dependent positions.
CmdAtom atom = atom_from_redo(kAOperator);
size_t plen = typed.opfunc_end - typed.opfunc_start;
if (atom.keys != NULL && plen > 0) {
// The opfunc's payload is absent from the captured redo: append it.
size_t klen = strlen(atom.keys);
atom.keys = xrealloc(atom.keys, klen + plen + 1);
memcpy(atom.keys + klen, typed.keys.items + typed.opfunc_start, plen);
atom.keys[klen + plen] = NUL;
}
// The payload ('operatorfunc' getchar()) is not in the captured redo, append it.
atom_payload_append(&atom, old);
// Cascade only on an OBSERVABLE effect: an edit or register write. A redoable operator that
// did neither is a no-op (vim-surround "ysa[" whose surround char was <Esc>).
bool effect = changed || reg_max_ts(true) > old->reg_ts;
@@ -1261,10 +1344,11 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf);
atom.origin = old->origin;
atom_push(false, atom);
} else if (!vis && curcmd.cmdline != NULL
&& (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) {
} else if (!vis && curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) {
// Same for ":cnext<CR>" or "<Cmd>cnext<CR>". Never a Visual subatom.
CmdAtom atom = atom_from_cmdline(kAExcmd, ca, curcmd.cmdline);
// Payload read during the cmdline execution (`ds)` getchar() => ")").
atom_payload_append(&atom, old);
atom.origin = old->origin;
atom_push(false, atom);
} else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) {
@@ -1297,24 +1381,34 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
// Not replayable: edited buffer during selection, so the keys do not describe the change.
vatom.state |= kVatomVoid;
}
if (Visual.active && user && toplevel) {
if (Visual.active && user && old->parent == NULL) {
mc_vsel_refresh();
}
}
/// Completes a cmd at normal_execute() exit: captures its atom, pushes its staged one, ends the
/// composite. Pops the frame.
void atom_cmd_end(cmdarg_T *ca, CmdFrame *old, bool toplevel)
void atom_cmd_end(cmdarg_T *ca, CmdFrame *old)
{
atom_capture_cmd(ca, old, toplevel);
atom_capture_cmd(ca, old);
atom_stage_flush(old);
if (atom_composite_active() && old->payload_start != SIZE_MAX
&& old->payload_end > old->payload_start) {
// An eval-read payload with no capturing atom (Lua mapping's getchar()).
composite.lossy = true;
}
// The clock edge. Only at toplevel: cascading from a nested normal_execute() would recurse.
// Deferred while a mapping executes (its keys are still in typebuf), so its commands collapse as
// one unit; likewise for a macro's LAST command, which may stuff a translation ("x" => "dl").
if (toplevel && typebuf_typed() && stuff_empty()) {
if (old->parent == NULL && !mc_replaying() && typebuf_typed() && stuff_empty()) {
mc_clock_edge(map_edit);
map_edit = false;
atom_composite_end(ca->oap->op_type != OP_NOP ? "operator" : Visual.active ? "visual" : "");
// Mapping contains its continuation. While op-pending, selection-active, or insert-will-resume
// (i_CTRL-O), composite keeps collecting: ",Dw" (":nnoremap ,D d") is one atom, `keys="dw"`.
if (ca->oap->op_type == OP_NOP && !Visual.active && restart_edit == 0) {
atom_composite_end();
}
}
cur_frame = old->parent;
}

View File

@@ -20,10 +20,13 @@ struct CmdFrame {
VisualState visual; ///< Visual-mode state (active/start/mode are diffed).
bool keytyped; ///< KeyTyped
uint64_t captures; ///< Capture counter.
uint64_t id; ///< Identifies this frame (see `composite.frame`).
bool follow; ///< mc_following() ("q=")
bool consumers; ///< Capture is skipped if there are no consumers (for performance).
Timestamp reg_ts; ///< Max register timestamp (to detect a per-cursor register write).
CmdAtom staged; ///< Atom staged in this frame. `keys == NULL`: none.
CmdAtom staged; ///< Atom staged in this frame (`keys=NULL`: none).
size_t payload_start; ///< Payload slice of key stream (`SIZE_MAX`: none):
size_t payload_end; ///< `typed.keys[payload_start..payload_end)`
CmdFrame *parent; ///< Enclosing frame (nested normal_execute()); NULL at toplevel.
};

View File

@@ -71,8 +71,8 @@ struct CmdAtom {
int undoseq; ///< Undo state at settlement. Not monotonic (decreases on undo).
bool changed; ///< The command changed the buffer.
bool moved; ///< The command moved the cursor.
bool remap; ///< Replay `keys` w/ remap. For replay of a payload mapping (vim-surround "ds'"),
///< which edits invisibly (:norm/Ex) and must rerun LHS instead of resolved keys.
bool remap; ///< If true, `keys` cannot replay: payload mapping (vim-surround "ds'") edits
///< invisibly (:norm/Ex). Must replay `lhs` instead.
};
/// Key classes (atom_key_class()).

View File

@@ -164,6 +164,7 @@ typedef void (*nv_func_T)(cmdarg_T *cap);
#define NV_NCW 0x200 // not allowed in command-line window
#define NV_NCH_ARG 0x400 // second char is a typed operand (mark/register name),
// not part of the command name (see NV_LANG for f/t/r)
#define NV_MOTION 0x800 // Motion command.
// Generally speaking, every Normal mode command should either clear any
// pending operator (with *clearop*()), or set the motion type variable
@@ -190,15 +191,15 @@ static const struct nv_cmd {
{ Ctrl_E, nv_scroll_line, 0, true },
{ Ctrl_F, nv_page, NV_STS, FORWARD },
{ Ctrl_G, nv_ctrlg, 0, 0 },
{ Ctrl_H, nv_ctrlh, 0, 0 },
{ Ctrl_H, nv_ctrlh, NV_MOTION, 0 },
{ Ctrl_I, nv_pcmark, 0, 0 },
{ NL, nv_down, 0, false },
{ NL, nv_down, NV_MOTION, false },
{ Ctrl_K, nv_error, 0, 0 },
{ Ctrl_L, nv_clear, 0, 0 },
{ CAR, nv_down, 0, true },
{ Ctrl_N, nv_down, NV_STS, false },
{ CAR, nv_down, NV_MOTION, true },
{ Ctrl_N, nv_down, NV_STS|NV_MOTION, false },
{ Ctrl_O, nv_ctrlo, 0, 0 },
{ Ctrl_P, nv_up, NV_STS, false },
{ Ctrl_P, nv_up, NV_STS|NV_MOTION, false },
{ Ctrl_Q, nv_visual, 0, false },
{ Ctrl_R, nv_redo_or_register, 0, 0 },
{ Ctrl_S, nv_ignore, 0, 0 },
@@ -216,23 +217,23 @@ static const struct nv_cmd {
{ Ctrl_RSB, nv_ident, NV_NCW, 0 },
{ Ctrl_HAT, nv_hat, NV_NCW, 0 },
{ Ctrl__, nv_error, 0, 0 },
{ ' ', nv_right, 0, 0 },
{ ' ', nv_right, NV_MOTION, 0 },
{ '!', nv_operator, 0, 0 },
{ '"', nv_regname, NV_NCH_NOP|NV_NCH_ARG|NV_KEEPREG, 0 },
{ '#', nv_ident, 0, 0 },
{ '$', nv_dollar, 0, 0 },
{ '%', nv_percent, 0, 0 },
{ '$', nv_dollar, NV_MOTION, 0 },
{ '%', nv_percent, NV_MOTION, 0 },
{ '&', nv_optrans, 0, 0 },
{ '\'', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, true },
{ '(', nv_brace, 0, BACKWARD },
{ ')', nv_brace, 0, FORWARD },
{ '(', nv_brace, NV_MOTION, BACKWARD },
{ ')', nv_brace, NV_MOTION, FORWARD },
{ '*', nv_ident, 0, 0 },
{ '+', nv_down, 0, true },
{ ',', nv_csearch, 0, true },
{ '-', nv_up, 0, true },
{ '+', nv_down, NV_MOTION, true },
{ ',', nv_csearch, NV_MOTION, true },
{ '-', nv_up, NV_MOTION, true },
{ '.', nv_dot, NV_KEEPREG, 0 },
{ '/', nv_search, 0, false },
{ '0', nv_beginline, 0, 0 },
{ '0', nv_beginline, NV_MOTION, 0 },
{ '1', nv_ignore, 0, 0 },
{ '2', nv_ignore, 0, 0 },
{ '3', nv_ignore, 0, 0 },
@@ -243,71 +244,71 @@ static const struct nv_cmd {
{ '8', nv_ignore, 0, 0 },
{ '9', nv_ignore, 0, 0 },
{ ':', nv_colon, 0, 0 },
{ ';', nv_csearch, 0, false },
{ ';', nv_csearch, NV_MOTION, false },
{ '<', nv_operator, NV_RL, 0 },
{ '=', nv_operator, 0, 0 },
{ '>', nv_operator, NV_RL, 0 },
{ '?', nv_search, 0, false },
{ '@', nv_at, NV_NCH_NOP|NV_NCH_ARG, false },
{ 'A', nv_edit, 0, 0 },
{ 'B', nv_bck_word, 0, 1 },
{ 'B', nv_bck_word, NV_MOTION, 1 },
{ 'C', nv_abbrev, NV_KEEPREG, 0 },
{ 'D', nv_abbrev, NV_KEEPREG, 0 },
{ 'E', nv_wordcmd, 0, true },
{ 'F', nv_csearch, NV_NCH_ALW|NV_LANG, BACKWARD },
{ 'G', nv_goto, 0, true },
{ 'H', nv_scroll, 0, 0 },
{ 'E', nv_wordcmd, NV_MOTION, true },
{ 'F', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, BACKWARD },
{ 'G', nv_goto, NV_MOTION, true },
{ 'H', nv_scroll, NV_MOTION, 0 },
{ 'I', nv_edit, 0, 0 },
{ 'J', nv_join, 0, 0 },
{ 'K', nv_ident, 0, 0 },
{ 'L', nv_scroll, 0, 0 },
{ 'M', nv_scroll, 0, 0 },
{ 'N', nv_next, 0, SEARCH_REV },
{ 'L', nv_scroll, NV_MOTION, 0 },
{ 'M', nv_scroll, NV_MOTION, 0 },
{ 'N', nv_next, NV_MOTION, SEARCH_REV },
{ 'O', nv_open, 0, 0 },
{ 'P', nv_put, 0, 0 },
{ 'Q', nv_regreplay, 0, 0 },
{ 'R', nv_Replace, 0, false },
{ 'S', nv_subst, NV_KEEPREG, 0 },
{ 'T', nv_csearch, NV_NCH_ALW|NV_LANG, BACKWARD },
{ 'T', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, BACKWARD },
{ 'U', nv_Undo, 0, 0 },
{ 'W', nv_wordcmd, 0, true },
{ 'W', nv_wordcmd, NV_MOTION, true },
{ 'X', nv_abbrev, NV_KEEPREG, 0 },
{ 'Y', nv_abbrev, NV_KEEPREG, 0 },
{ 'Z', nv_Zet, NV_NCH_NOP|NV_NCW, 0 },
{ '[', nv_brackets, NV_NCH_ALW, BACKWARD },
{ '\\', nv_error, 0, 0 },
{ ']', nv_brackets, NV_NCH_ALW, FORWARD },
{ '^', nv_beginline, 0, BL_WHITE | BL_FIX },
{ '_', nv_lineop, 0, 0 },
{ '^', nv_beginline, NV_MOTION, BL_WHITE | BL_FIX },
{ '_', nv_lineop, NV_MOTION, 0 },
{ '`', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, false },
{ 'a', nv_edit, NV_NCH, 0 },
{ 'b', nv_bck_word, 0, 0 },
{ 'b', nv_bck_word, NV_MOTION, 0 },
{ 'c', nv_operator, 0, 0 },
{ 'd', nv_operator, 0, 0 },
{ 'e', nv_wordcmd, 0, false },
{ 'f', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD },
{ 'e', nv_wordcmd, NV_MOTION, false },
{ 'f', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, FORWARD },
{ 'g', nv_g_cmd, NV_NCH_ALW, false },
{ 'h', nv_left, NV_RL, 0 },
{ 'h', nv_left, NV_RL|NV_MOTION, 0 },
{ 'i', nv_edit, NV_NCH, 0 },
{ 'j', nv_down, 0, false },
{ 'k', nv_up, 0, false },
{ 'l', nv_right, NV_RL, 0 },
{ 'j', nv_down, NV_MOTION, false },
{ 'k', nv_up, NV_MOTION, false },
{ 'l', nv_right, NV_RL|NV_MOTION, 0 },
{ 'm', nv_mark, NV_NCH_NOP|NV_NCH_ARG, 0 },
{ 'n', nv_next, 0, 0 },
{ 'n', nv_next, NV_MOTION, 0 },
{ 'o', nv_open, 0, 0 },
{ 'p', nv_put, 0, 0 },
{ 'q', nv_q, NV_NCH|NV_NCH_ARG, 0 },
{ 'r', nv_replace, NV_NCH_NOP|NV_LANG, 0 },
{ 's', nv_subst, NV_KEEPREG, 0 },
{ 't', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD },
{ 't', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, FORWARD },
{ 'u', nv_undo, 0, 0 },
{ 'w', nv_wordcmd, 0, false },
{ 'w', nv_wordcmd, NV_MOTION, false },
{ 'x', nv_abbrev, NV_KEEPREG, 0 },
{ 'y', nv_operator, 0, 0 },
{ 'z', nv_zet, NV_NCH_ALW, 0 },
{ '{', nv_findpar, 0, BACKWARD },
{ '|', nv_pipe, 0, 0 },
{ '}', nv_findpar, 0, FORWARD },
{ '{', nv_findpar, NV_MOTION, BACKWARD },
{ '|', nv_pipe, NV_MOTION, 0 },
{ '}', nv_findpar, NV_MOTION, FORWARD },
{ '~', nv_tilde, 0, 0 },
// pound sign
@@ -338,29 +339,29 @@ static const struct nv_cmd {
{ K_NOP, nv_nop, 0, 0 },
{ K_INS, nv_edit, 0, 0 },
{ K_KINS, nv_edit, 0, 0 },
{ K_BS, nv_ctrlh, 0, 0 },
{ K_UP, nv_up, NV_SSS|NV_STS, false },
{ K_BS, nv_ctrlh, NV_MOTION, 0 },
{ K_UP, nv_up, NV_SSS|NV_STS|NV_MOTION, false },
{ K_S_UP, nv_page, NV_SS, BACKWARD },
{ K_DOWN, nv_down, NV_SSS|NV_STS, false },
{ K_DOWN, nv_down, NV_SSS|NV_STS|NV_MOTION, false },
{ K_S_DOWN, nv_page, NV_SS, FORWARD },
{ K_LEFT, nv_left, NV_SSS|NV_STS|NV_RL, 0 },
{ K_S_LEFT, nv_bck_word, NV_SS|NV_RL, 0 },
{ K_C_LEFT, nv_bck_word, NV_SSS|NV_RL|NV_STS, 1 },
{ K_RIGHT, nv_right, NV_SSS|NV_STS|NV_RL, 0 },
{ K_S_RIGHT, nv_wordcmd, NV_SS|NV_RL, false },
{ K_C_RIGHT, nv_wordcmd, NV_SSS|NV_RL|NV_STS, true },
{ K_LEFT, nv_left, NV_SSS|NV_STS|NV_RL|NV_MOTION, 0 },
{ K_S_LEFT, nv_bck_word, NV_SS|NV_RL|NV_MOTION, 0 },
{ K_C_LEFT, nv_bck_word, NV_SSS|NV_RL|NV_STS|NV_MOTION, 1 },
{ K_RIGHT, nv_right, NV_SSS|NV_STS|NV_RL|NV_MOTION, 0 },
{ K_S_RIGHT, nv_wordcmd, NV_SS|NV_RL|NV_MOTION, false },
{ K_C_RIGHT, nv_wordcmd, NV_SSS|NV_RL|NV_STS|NV_MOTION, true },
{ K_PAGEUP, nv_page, NV_SSS|NV_STS, BACKWARD },
{ K_KPAGEUP, nv_page, NV_SSS|NV_STS, BACKWARD },
{ K_PAGEDOWN, nv_page, NV_SSS|NV_STS, FORWARD },
{ K_KPAGEDOWN, nv_page, NV_SSS|NV_STS, FORWARD },
{ K_END, nv_end, NV_SSS|NV_STS, false },
{ K_KEND, nv_end, NV_SSS|NV_STS, false },
{ K_S_END, nv_end, NV_SS, false },
{ K_C_END, nv_end, NV_SSS|NV_STS, true },
{ K_HOME, nv_home, NV_SSS|NV_STS, 0 },
{ K_KHOME, nv_home, NV_SSS|NV_STS, 0 },
{ K_S_HOME, nv_home, NV_SS, 0 },
{ K_C_HOME, nv_goto, NV_SSS|NV_STS, false },
{ K_END, nv_end, NV_SSS|NV_STS|NV_MOTION, false },
{ K_KEND, nv_end, NV_SSS|NV_STS|NV_MOTION, false },
{ K_S_END, nv_end, NV_SS|NV_MOTION, false },
{ K_C_END, nv_end, NV_SSS|NV_STS|NV_MOTION, true },
{ K_HOME, nv_home, NV_SSS|NV_STS|NV_MOTION, 0 },
{ K_KHOME, nv_home, NV_SSS|NV_STS|NV_MOTION, 0 },
{ K_S_HOME, nv_home, NV_SS|NV_MOTION, 0 },
{ K_C_HOME, nv_goto, NV_SSS|NV_STS|NV_MOTION, false },
{ K_DEL, nv_abbrev, 0, 0 },
{ K_KDEL, nv_abbrev, 0, 0 },
{ K_UNDO, nv_kundo, 0, 0 },
@@ -431,6 +432,14 @@ bool nv_nchar_is_arg(int cmdchar)
return idx >= 0 && (nv_cmds[idx].cmd_flags & (NV_LANG|NV_NCH_ARG)) != 0;
}
/// True if `cmdchar` is a motion command. Multiplexed handlers (g, [, ], z) are classified by
/// atom_key_class().
bool nv_is_motion(int cmdchar)
{
int idx = find_command(cmdchar);
return idx >= 0 && (nv_cmds[idx].cmd_flags & NV_MOTION) != 0;
}
/// Search for a command in the commands table.
///
/// @return -1 for invalid command.
@@ -1085,15 +1094,6 @@ normal_end:
static int normal_execute(VimState *state, int key)
{
// Like most things in Vim, `toplevel` is a lie: exec_normal() happily sets it to true (inherited
// from Vim). So we track `depth` instead.
// - depth=0: No command executing (idle, between commands, sitting in vgetc()).
// - depth=1: Outermost command-frame.
// - depth=2: First nested frame (e.g. the "dd" inside ":normal! dd").
// - depth=n: And so on...
static int depth = 0;
depth++;
CmdFrame frame;
atom_cmd_start(&frame);
@@ -1269,9 +1269,8 @@ static int normal_execute(VimState *state, int key)
finish:
normal_finish_command(s);
atom_cmd_end(&s->ca, &frame, depth == 1);
atom_cmd_end(&s->ca, &frame);
xfree(s->ca.searchbuf);
depth--;
return 1;
}
@@ -1563,14 +1562,14 @@ void restore_visual_mode(void)
}
}
/// Check for a balloon-eval special item to include when searching for an
/// identifier. When "dir" is BACKWARD "ptr[-1]" must be valid!
/// Check for a <cexpr> special item to include when searching for an identifier: "ptr->arg",
/// "list[idx]", "s.var". When `dir` is BACKWARD `ptr[-1]` must be valid!
///
/// @return true if the character at "*ptr" should be included.
/// @return true if the character at `*ptr` should be included.
///
/// @param dir the direction of searching, is either FORWARD or BACKWARD
/// @param *colp is in/decremented if "ptr[-dir]" should also be included.
/// @param bnp points to a counter for square brackets.
/// @param dir Direction of searching, either FORWARD or BACKWARD.
/// @param *colp Is in/decremented if "ptr[-dir]" should also be included.
/// @param bnp Points to a counter for square brackets.
static bool find_is_eval_item(const char *const ptr, int *const colp, int *const bnp, const int dir)
{
// Accept everything inside [].
@@ -1615,7 +1614,7 @@ static bool find_is_eval_item(const char *const ptr, int *const colp, int *const
/// - FIND_IDENT: find an identifier (keyword)
/// - FIND_STRING: find any non-white text
/// - FIND_IDENT + FIND_STRING: find any non-white text, identifier preferred.
/// - FIND_EVAL: find text useful for C program debugging
/// - FIND_EVAL: also include "->", "[]" and "." (<cexpr>)
/// @param offset If not NULL, gets cursor position relative to start of `text`.
/// @return Text length, or zero if no text is found.
size_t find_ident_under_cursor(char **text, int find_type, int *offset)

View File

@@ -3687,10 +3687,8 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank)
case OP_FUNCTION:
// Restore linebreak, so that when the user edits it looks as before.
restore_lbr(lbr_saved);
atom_opfunc_slice(true); // Keys the opfunc reads interactively (getchar) are its payload.
// call 'operatorfunc'
op_function(oap);
atom_opfunc_slice(false);
break;
case OP_INSERT:

View File

@@ -78,6 +78,16 @@ m.minisurround_vim = [[
nnoremap <expr> ys MiniSurroundSetup()
]]
--- Minimal vim-sneak: :omap whose ":call" reads a 2-char getchar() and moves the cursor.
m.minisneak_vim = [[
function! MiniSneak() abort
let c1 = nr2char(getchar())
let c2 = nr2char(getchar())
call search('\V' . c1 . c2, 'W')
endfunction
onoremap <silent> z :<C-U>call MiniSneak()<CR>
]]
--- Minimal vim-surround "ds": a ":call" mapping whose edit runs through :normal inside a
--- function, with a getchar() payload naming the surround to delete.
m.delsurround_vim = [[

View File

@@ -52,6 +52,38 @@ describe('dot-repeat', function()
feed('j0.')
eq({ 'acbzacbonexy', 'czacbtwo' }, get_lines())
end)
it('of a visual op does not churn showcmd', function()
local screen = Screen.new(40, 8, { ext_messages = true })
command('set showcmd')
fn.setline(1, { 'foo bar', 'longword two' })
feed('gg0viWgU')
feed('2gg0')
screen:expect({ any = '%^longword' })
local updates = {}
function screen:_handle_msg_showcmd(msg)
local text = table.concat(vim.tbl_map(function(chunk)
return chunk[2]
end, msg))
if text ~= '' then
updates[#updates + 1] = text
end
self.showcmd = msg
end
feed('.')
retry(nil, nil, function()
eq({ 'FOO bar', 'LONGWORD two' }, get_lines())
end)
screen:sleep(50)
-- Stuffed (replayed) keys must not display in 'showcmd': ext_messages UIs redraw per showcmd
-- change, which would paint the replay's transient states (its in-progress Visual selection).
eq(
{},
vim.tbl_filter(function(s)
return s:find('[UW]') ~= nil
end, updates)
)
end)
end)
describe('CmdAtom', function()
@@ -107,7 +139,7 @@ describe('CmdAtom', function()
feed(']q')
eq(3, fn.line('.')) -- the mapping did run (:cnext)
eq(
{ type = 'mapping', lhs = ']q', keys = '', changed = false },
{ type = 'mapping', lhs = ']q', changed = false },
pick(atom_last(), 'type', 'lhs', 'keys', 'changed')
)
-- An empty-keys mapping that DOES edit still reports it: `changed` is
@@ -118,7 +150,7 @@ describe('CmdAtom', function()
end)
]])
feed(',e')
eq({ keys = '', lhs = ',e', changed = true }, pick(atom_last(), 'keys', 'lhs', 'changed'))
eq({ lhs = ',e', changed = true }, pick(atom_last(), 'keys', 'lhs', 'changed'))
-- "<Cmd>" is opaque too, but unlike a Lua callback its command is text (like a ":" mapping).
command('nnoremap ,c <Cmd>call setline(1, "N" . v:count)<CR>')
@@ -138,6 +170,25 @@ describe('CmdAtom', function()
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(cmdev.keys))
eq('N3', fn.getline(1))
-- <expr> mapping that returns a "<Cmd>lua …<CR>" (dot-repeat idiom #41387) captures the same
-- way: the constructed command is the atom.
n.exec_lua([[
vim.keymap.set('n', ',x', function()
return '<Cmd>call setline(1, "E" . v:count1)<CR>'
end, { expr = true })
]])
feed('2,x')
cmdev = atom_last()
eq({
type = 'excmd',
lhs = ',x',
keys = k('2<Cmd>call setline(1, "E" . v:count1)<NL>'),
count = 2,
}, pick(cmdev, 'type', 'lhs', 'keys', 'count'))
fn.setline(1, 'reset')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(cmdev.keys))
eq('E2', fn.getline(1))
-- Opaque key that changes nothing is invisible: mid-selection it must not void the pending
-- visual atom, which would void its per-cursor extents.
command('vnoremap ,n <Cmd>call execute("")<CR>')
@@ -211,12 +262,11 @@ describe('CmdAtom', function()
feed('vf,d')
eq({ 'ef' }, get_lines())
eq({ '3dl', 'vf,d' }, atoms_tail(2))
eq(3, atoms()[#atoms() - 1].count)
-- Structured decomposition: operator/motion/operand as fields, no byte-parsing.
local op = atoms()[#atoms() - 1] -- "3dl"
eq(
{ operator = 'd', cmd = 'l', changed = true },
pick(op, 'operator', 'cmd', 'cmdarg', 'motionforce', 'changed')
{ operator = 'd', cmd = 'l', count = 3, changed = true },
pick(op, 'operator', 'cmd', 'cmdarg', 'count', 'motionforce', 'changed')
)
-- A visual atom carries the completing operator's fields, and decomposes
-- into its commands ("v", "f," and the operator).
@@ -357,24 +407,15 @@ describe('CmdAtom', function()
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq({ ' bar', ' bar' }, get_lines())
-- Builtin "." replays the same keysequence, not Vim's equal-size reselect.
api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' })
feed('gg0viwd')
eq({ ' bar', 'longword bar' }, get_lines())
feed('j0.')
eq({ ' bar', ' bar' }, get_lines())
-- A viewport scroll that drags the cursor along (edge/'scrolloff') grows
-- the selection by a viewport-dependent amount: void, no atom.
local lines = {}
for i = 1, 30 do
lines[i] = 'l' .. i
end
local lines = vim.tbl_map(function(i)
return 'l' .. i
end, fn.range(1, 30))
fn.setline(1, lines)
feed('gg')
local before = #atoms()
feed('V<C-e>')
n.poke_eventloop()
eq(2, fn.line('.')) -- the scroll dragged the cursor: selection is lines 1-2
feed('d')
-- Publishes with empty `CmdAtom.keys`; the keys that produced it are in `lhs`.
@@ -489,13 +530,9 @@ describe('CmdAtom', function()
]])
--- Count of captured visual atoms.
local function nvisual()
local count = 0
for _, a in ipairs(atoms()) do
if a.type == 'visual' then
count = count + 1
end
end
return count
return #vim.tbl_filter(function(a)
return a.type == 'visual'
end, atoms())
end
fn.setline(1, { 'foo bar', 'longword bar' })
feed('gg0')
@@ -562,18 +599,16 @@ describe('CmdAtom', function()
it('one event per user action', function()
n.clear({ args = { '--clean' }, args_rm = { '--cmd' } })
--- Feeds `keys`, asserts exactly ONE new event, with the given keys.
local function atom(keys, expected, lhs)
local function atom(keys, expected, lhs, type_)
local before = #atoms()
feed(keys)
local evs = atoms()
eq({ before + 1, k(expected) }, { #evs, evs[#evs].keys })
eq(k(lhs or expected), evs[#evs].lhs)
eq(
{ before + 1, k(expected), k(lhs or expected), type_ or evs[#evs].type },
{ #evs, evs[#evs].keys, evs[#evs].lhs, evs[#evs].type }
)
end
local lines = {}
for i = 1, 20 do
lines[i] = 'alpha beta gamma delta epsilon zeta'
end
fn.setline(1, lines)
fn.setline(1, fn['repeat']({ 'alpha beta gamma delta epsilon zeta' }, 20))
feed('gg0')
atoms_start()
-- "." with nothing to repeat stuffs nothing.
@@ -622,32 +657,22 @@ describe('CmdAtom', function()
command('silent! nunmap %') -- the bundled matchit plugin maps it
fn.setline(1, 'alpha (beta) gamma')
feed('gg0f(')
atom('%', '%')
eq('motion', atom_last().type)
atom('%', '%', nil, 'motion')
fn.setline(1, 'alpha beta gamma delta epsilon zeta')
-- G/gg (absolute line), H/M/L (viewport) are motions: multicursor replay is meaningful? (but
-- cursors may be "merged").
feed('gg0')
atom('G', 'G')
eq('motion', atom_last().type)
atom('gg', 'gg')
eq('motion', atom_last().type)
atom('L', 'L')
eq('motion', atom_last().type)
atom('H', 'H')
eq('motion', atom_last().type)
atom('M', 'M')
eq('motion', atom_last().type)
atom('G', 'G', nil, 'motion')
atom('gg', 'gg', nil, 'motion')
atom('L', 'L', nil, 'motion')
atom('H', 'H', nil, 'motion')
atom('M', 'M', nil, 'motion')
-- Jumps: absolute/shared-state navigation, their own kind.
atom('ma', 'ma')
eq('normal', atom_last().type) -- "m" sets state; it does not jump
atom('`a', '`a')
eq('jump', atom_last().type)
atom('<C-o>', '<C-O>')
eq('jump', atom_last().type)
atom('ma', 'ma', nil, 'normal') -- "m" sets state; it does not jump
atom('`a', '`a', nil, 'jump')
atom('<C-o>', '<C-O>', nil, 'jump')
-- Non-redoable commands: still emitted, as type "command".
atom('zz', 'zz')
eq('normal', atom_last().type)
atom('zz', 'zz', nil, 'normal')
atom('u', 'u')
atom('<C-r>', '<C-R>')
-- "." emits its resolution (like "x" => "dl").
@@ -674,23 +699,19 @@ describe('CmdAtom', function()
-- A nested cmdline opened by the command's own execution (":normal")
-- does not hijack the payload.
atom(':exe "normal! :echo 1\\r"<CR>', ':exe<Space>"normal!<Space>:echo<Space>1<Bslash>r"<NL>')
-- A mapping is ALWAYS an atom, even when its commands capture no
-- replayable keys ("]q" = :cnext): the event has empty keys.
fn.setqflist({ { text = 'one' }, { text = 'two' } })
feed(']q')
eq(
{ type = 'mapping', lhs = ']q', keys = '' },
pick(atom_last(), 'type', 'lhs', 'keys', 'pending')
)
-- A mapping that ends mid-operation says what it awaits.
-- A mapping that ends mid-operation captures its continuation. ",D" emits nothing until the
-- typed "w" finishes the operation: one atom captures both.
command('nnoremap ,D d')
command('nnoremap ,V v')
before = #atoms()
feed(',D')
eq({ lhs = ',D', pending = 'operator' }, pick(atom_last(), 'lhs', 'pending'))
atom('w', 'dw') -- the supplied motion completes the operation
eq(before, #atoms())
atom('w', 'dw', ',Dw') -- The supplied motion finishes the operation
-- Same for a mapping that opens Visual mode: "<Esc>" abandons the selection and closes it.
feed(',V')
eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending'))
eq(before + 1, #atoms())
feed('<Esc>')
eq({ type = 'mapping', lhs = k(',V<Esc>') }, pick(atom_last(), 'type', 'lhs', 'keys'))
-- A mapping whose trailing prefix is completed by TYPED keys ("," + "w")
-- ends where its own keys stop: each `lhs` owns only what it produced.
command('set notimeout')
@@ -701,9 +722,9 @@ describe('CmdAtom', function()
feed(',xw') -- no poke between: nvim awaits the pending "," under 'notimeout'
eq(before + 2, #atoms())
eq({
{ type = 'motion', lhs = ',x', keys = 'j', pending = 'mapping' },
{ type = 'motion', lhs = ',x', keys = 'j' },
{ type = 'motion', lhs = ',w', keys = 'w' },
}, atoms_tail(2, 'type', 'lhs', 'keys', 'pending'))
}, atoms_tail(2, 'type', 'lhs', 'keys'))
command('set notimeout&')
command('nunmap ,x')
command('nunmap ,w')
@@ -771,7 +792,7 @@ describe('CmdAtom', function()
-- mapping: one event, keeping the command's own type and structure).
local evs = atoms()
eq(1, #evs)
-- Inapplicable fields (count/reg/arg/motionforce/text/pending/atoms here) are omitted.
-- Inapplicable fields (count/reg/arg/motionforce/text/atoms here) are omitted.
eq({
type = 'operator',
keys = 'dw',
@@ -880,34 +901,75 @@ describe('CmdAtom', function()
feed('gg0')
atoms_start()
feed('ysiw"')
-- The atom is the redobuff plus the getchar()'d payload: a replayed opfunc reads the same wrap
-- char.
-- Two atoms: the mapping ends mid-operation ("ys" => "g@"), then the operator the typed "iw"
-- completed. Only the mapping has a translated `lhs`; the operator is its own input.
eq({
{ type = 'mapping', keys = '', lhs = 'ys', pending = 'operator' },
{ type = 'operator', keys = 'g@iw"', lhs = 'g@iw"' },
}, atoms_tail(2, 'type', 'keys', 'lhs', 'pending'))
-- One atom: the mapping captures the operation finished by "iw". `keys` is the redobuff plus
-- the getchar() payload: a replayed opfunc reads the same wrap char.
eq(
{ type = 'operator', keys = 'g@iw"', lhs = 'ysiw"' },
pick(atom_last(), 'type', 'keys', 'lhs')
)
eq({ '"alpha" beta' }, get_lines())
end)
it('a ":call" payload mapping publishes its resolved RHS', function()
-- The atom published to CmdAtom carries the RESOLVED RHS (the ":call" line).
it('":call" payload mapping appends payload to `keys`', function()
n.exec(t_atom.delsurround_vim)
fn.setline(1, { 'a (one)', 'b (two)' })
feed('gg0f(')
atoms_start()
feed('ds)') -- ")" is the getchar()'d payload
eq({ 'a one', 'b (two)' }, get_lines())
local ev = atoms()[#atoms()]
t.matches(':call DelSurround%(%)', ev.keys)
-- Replaying those keys would prompt for the payload again, so the atom asks
-- for LHS-replay instead: `lhs` carries the payload, `remap` says to remap.
eq({ lhs = 'ds)', remap = true }, pick(ev, 'lhs', 'remap'))
local ev = atom_last()
-- getchar() payload is appended to `keys`, replayable.
eq({ lhs = 'ds)', keys = ':call DelSurround()\n)' }, pick(ev, 'lhs', 'keys'))
feed('2G0f(')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'm', false)]]):format(ev.lhs))
n.poke_eventloop()
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq({ 'a one', 'b two' }, get_lines())
-- input() payload is appended to `keys`, replayable.
n.exec([[
function! Suffix() abort
call setline('.', getline('.') .. input('suffix: '))
endfunction
nnoremap ,s :<C-U>call Suffix()<CR>
]])
feed('gg')
feed(',sX<CR>')
n.poke_eventloop()
eq('a oneX', fn.getline(1))
ev = atom_last()
eq({ lhs = k(',sX<CR>'), keys = ':call Suffix()\nX\r' }, pick(ev, 'lhs', 'keys'))
feed('j')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq('b twoX', fn.getline(2))
-- Operator-pending mapping (:omap custom motion, like vim-sneak "z") fully captured.
n.exec(t_atom.minisneak_vim)
api.nvim_buf_set_lines(0, 0, -1, true, { 'aa (x) here', 'bb (y) here' })
feed('gg0')
n.poke_eventloop()
feed('dzhe')
n.poke_eventloop()
eq('here', fn.getline(1))
ev = atom_last()
eq(
{ type = 'operator', operator = 'd', lhs = 'dzhe', keys = 'd:call MiniSneak()\nhe' },
pick(ev, 'type', 'operator', 'lhs', 'keys')
)
feed('j0')
n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys))
eq('here', fn.getline(2))
-- Burst input ("f(" and the mapping arrive together): "f" peeks for a composing char with
-- mappings enabled, so "ds" resolves while "f(" is still executing. The motion is still its
-- own atom, and the K_IGNORE left by the peek stays out of `lhs`.
api.nvim_buf_set_lines(0, 0, -1, true, { 'a (one)' })
feed('gg0')
n.poke_eventloop()
feed('f(ds)')
eq({ 'a one' }, get_lines())
eq({
{ type = 'motion', keys = 'f(', lhs = 'f(' },
{ type = 'excmd', keys = ':call DelSurround()\n)', lhs = 'ds)' },
}, atoms_tail(2, 'type', 'keys', 'lhs'))
end)
it('|restore-undo-cursor|: `pos` + `undoseq` restore across every undo form', function()
@@ -983,10 +1045,7 @@ describe('CmdAtom', function()
_G.save = function() _G.saved = _G.last end
_G.replay = function()
local d = _G.saved
if not d.remap and d.keys == '' then
return -- Unreplayable Visual op: feeding `lhs` would be wrong.
end
vim.api.nvim_feedkeys(d.remap and d.lhs or d.keys, d.remap and 'm' or 'n', false)
vim.api.nvim_feedkeys(d.keys or d.lhs, d.keys and 'n' or 'm', false)
end
]])
n.exec(t_atom.delsurround_vim)
@@ -1015,11 +1074,11 @@ describe('CmdAtom', function()
n.poke_eventloop()
local l = get_lines()
local d = n.exec_lua('return _G.saved')
local subs ---@type string?
for _, c in ipairs(d.atoms or {}) do
subs = (subs or '') .. c.keys
end
return { lhs = d.lhs, keys = d.keys, remap = d.remap, subs = subs, replayed = l[1] == l[2] }
local subs = d.atoms
and table.concat(vim.tbl_map(function(c)
return c.keys
end, d.atoms))
return { lhs = d.lhs, keys = d.keys, subs = subs, replayed = l[1] == l[2] }
end
-- Asserts the table in runtime/doc/repeat.txt.
@@ -1030,9 +1089,10 @@ describe('CmdAtom', function()
{ lhs = ',d', keys = 'dl', replayed = true },
{ lhs = '@q', keys = 'dl', replayed = true },
{ lhs = k('<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 },
-- No `keys` (Lua callback): the recipe feeds `lhs` with remapping.
{ lhs = ']e', replayed = true },
-- getchar() ")" is appended to `keys`, replayable.
{ lhs = 'ds)', keys = ':call DelSurround()\n)', replayed = true },
}, {
both('a one two', '', 'dw'),
both('aaa bbb', '', 'x'),
@@ -1058,34 +1118,75 @@ describe('CmdAtom', function()
n.poke_eventloop()
eq({ 4, 6 }, { fn.foldclosed(4), fn.foldclosedend(4) }) -- "zf" closes what it creates
-- Unreplayable: a void Visual op has no keys AND no `remap`, so the recipe
-- skips it rather than replaying a viewport-dependent selection.
local lines = {}
for i = 1, 30 do
lines[i] = 'l' .. i
end
api.nvim_buf_set_lines(0, 0, -1, true, lines)
-- Unreplayable: a void Visual op has empty keys, so the recipe feeds nothing
-- rather than replaying a viewport-dependent selection.
api.nvim_buf_set_lines(
0,
0,
-1,
true,
vim.tbl_map(function(i)
return 'l' .. i
end, fn.range(1, 30))
)
feed('gg')
feed(k('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'))
eq({ keys = '' }, pick(n.exec_lua('return _G.saved'), 'keys'))
local before = get_lines()
n.exec_lua('_G.replay()')
n.poke_eventloop()
eq(before, get_lines())
-- A repeat mapping is an atom too, and `remap` sends the recipe back to its
-- own `lhs`: a recorder must skip it, or the repeat replays itself.
-- A repeat mapping is an atom too, and absent `keys` sends the recipe back to
-- its own `lhs`: a recorder must skip it, or the repeat replays itself.
n.exec_lua([[vim.keymap.set('n', '<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')
{ type = 'mapping', lhs = k('<F7>') },
pick(n.exec_lua('return _G.last'), 'type', 'keys', 'lhs')
)
end)
it('"." mapping repeats the last edit atom, plugin operations included', function()
n.exec_lua([[
local last ---@type vim.event.cmdatom.data?
vim.api.nvim_create_autocmd('CmdAtom', {
callback = function(ev)
if ev.data.changed and ev.data.lhs ~= '.' then
last = ev.data
end
end,
})
vim.keymap.set('n', '.', function()
vim.schedule(function()
if last then
vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false)
end
end)
end)
]])
n.exec(t_atom.delsurround_vim)
fn.setline(1, { 'aa bb', 'a (one)', 'b (two)' })
-- "Builtin" edit.
feed('gg0dw')
n.poke_eventloop()
feed('.')
retry(nil, 1000, function()
eq('', fn.getline(1))
end)
-- Payload mapping: builtin "." could not repeat this (without e.g. vim-repeat).
feed('2G0f(ds)')
n.poke_eventloop()
eq('a one', fn.getline(2))
feed('3G0f(.')
retry(nil, 1000, function()
eq('b two', fn.getline(3))
end)
end)
it('"," repeats the last motion atom', function()
-- Keep in sync with the example in runtime/doc/repeat.txt.
n.exec_lua([[
@@ -1247,20 +1348,28 @@ describe('CmdAtom', function()
]])
atoms_start()
-- A plugin motion via "<Cmd>" is type="ex" (its RHS implementation), but the
-- observed effect classifies it: moved, and did not change the buffer.
-- A plugin motion via "<Cmd>" is type="excmd" (its RHS implementation); `moved`
-- reports the same observed effect as the builtin motion below it.
feed('gg0]c')
eq(3, fn.line('.'))
eq(
{ type = 'excmd', moved = true, changed = false, pos = { 1, 0 } },
pick(atom_last(), 'type', 'moved', 'changed', 'pos')
)
-- Same observed effect as a builtin motion, which is the point.
feed('gg0w')
eq(
{ type = 'motion', moved = true, changed = false },
pick(atom_last(), 'type', 'moved', 'changed')
)
-- A motion that did not move is still a motion; `moved` is the separate question.
eq(
{ { 'motion', false }, { 'motion', false }, { 'motion', false }, { 'normal', false } },
vim.tbl_map(function(keys)
feed(keys)
local a = atom_last()
return { a.type, a.moved }
end, { 'G$w', 'gg0fz', 'gg0h', 'gg0zz' })
)
-- Register-only operator also moves without changing: a motion carries no `operator`.
feed('gg0wyb')