fix(cmdatom): lhs not always reported in CmdAtom #41386

Problem:
An unreplayable Visual operation does not emit a CmdAtom event. That's
maybe not super important, but it hints at a flaw in how `vatom`
"voiding" is plumbed: `vatom.state=kVatomVoid` replaces the "kind", so
that info is lost to later parts in the lifecycle.

Solution:
- Define `VatomState` as "flags", so `kVatomVoid` can "poison"
  `vatom.state` without losing its kind flag.
- Unify `lhs`: always report the original user input in `CmdAtom.lhs`,
  for all kinds of user actions: visual, "translated"/"stuffed" cmds,
  and dot-repeat (".") itself.
- Unreplayable Visual atom emits CmdAtom with non-empty `lhs` and empty
  `keys`, like a mapping/macro composite.
This commit is contained in:
Justin M. Keyes
2026-08-20 05:18:36 -04:00
committed by GitHub
parent bcf116cc2f
commit 31de0d69fc
5 changed files with 212 additions and 91 deletions

View File

@@ -425,6 +425,9 @@ CmdAtom After a user action (an "atom" of input): any
keysequence, Ex cmdline (":cnext<CR>"),
mapping (and its subatoms), or scroll/mouse.
Event is |deferred|.
<amatch> is the atom "type" (see below).
Only for user input, not programmatic input:
INPUT ATOM ~
typed keys yes
@@ -435,23 +438,18 @@ CmdAtom After a user action (an "atom" of input): any
|:normal| no
API requests no, lol
"@q" fed by a script no
"multicursor" replays no
|multicursor| cascade no
aborted operation no
terminal-mode keys no
mouse drag/release no
Hydrogen yes
Note: keys fed from a typed mapping's own
execution (e.g. its Lua callback) count as
the mapping's expansion and fold into its
atom, "t" or not.
Fired at the next event-loop tick, not
synchronously.
<amatch> (the pattern) is the atom type.
Note: all keys fed during a mapping execution
(e.g. Lua callback) are part of the mapping's
expansion.
The |event-data| has these fields (type: `vim.event.cmdatom.data`):
The |event-data| has these fields; a field
that does not apply is omitted (type, keys,
changed, and cascade are always present):
- arg: Typed operand of `cmd`: the "x" of "fx",
the replacement char of |r|.
|key-notation|, like `cmd`.
@@ -469,10 +467,14 @@ CmdAtom After a user action (an "atom" of input): any
raw internal bytes (not |key-notation|):
feed directly to |feedkeys()| or
|nvim_feedkeys()| (mode "n") to replay. Use
|keytrans()| to key-notation. Empty for
a mapping whose commands have no replayable
keys (Lua callbacks).
- lhs: LHS (user input). Raw bytes, like `keys`.
|keytrans()| to key-notation. Empty if
not replayable: a Lua mapping, or
unreplayable Visual operation (mouse, |gv|,
Select mode).
- lhs: User input, before it is
resolved/translated. Mapping LHS, macro
register ("@q"), or the keys of a Visual op.
Raw bytes, like `keys`.
- motionforce |forced-motion|: "v", "V", or
"<C-V>" (|key-notation|).
- operator: Operator: "d", "g@", "zf", ….
@@ -496,8 +498,6 @@ CmdAtom After a user action (an "atom" of input): any
- "jump": moves the cursor via
absolute/shared navigation state
(|jumplist|, marks, |star|).
- "mouse"/"scroll" atoms are emit-only:
never cascaded, not replayable.
*CmdlineChanged*
CmdlineChanged After EVERY change inside command line. Also

View File

@@ -49,6 +49,11 @@ static void mc_vsel_refresh(void)
{
}
static bool mc_following(void)
{
return false;
}
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)?".
@@ -58,10 +63,9 @@ 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;
/// Accumulating composite atom: the executing mapping/macro's subatoms, collected while it runs and
/// collapsed at the command end. See `vatom` for Visual composite.
/// Accumulating composite atom: an executing mapping/macro. See `vatom` for Visual composite.
static struct {
CmdAtomVec atoms;
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 macro; ///< Macro execution: captured as an "@x"-labeled atom.
@@ -73,18 +77,18 @@ static CmdFrame *cur_frame = NULL;
/// State of a Visual composite atom.
typedef enum {
// Nothing to replay:
kVatomNone, ///< No pending Visual atom.
kVatomVoid, ///< Not replayable: tainted/poisoned (by mouse, gv, …). But may emit CmdAtom.
kVatomNone = 0, ///< No pending Visual atom.
// Accumulating, replayable:
kVatomTyped, ///< User input (typed, or mapping/macro): emitted/cascaded at end.
kVatomFed, ///< Fed input (":norm! vjd", scheduled feedkeys): preps redo, no emit/cascade.
// Kind:
kVatomTyped = 1, ///< User input (typed, or mapping/macro): emitted/cascaded at end.
kVatomFed = 2, ///< Fed input (":norm! vjd", scheduled feedkeys): preps redo, no emit/cascade.
kVatomVoid = 4, ///< Not replayable: tainted/poisoned (by mouse, gv, …). But may emit CmdAtom.
} VatomState;
/// Accumulating Visual composite: the full Visual keysequence, including the selection steps.
/// Accumulating Visual composite: the full Visual keysequence (selection keys + operator).
static struct {
CmdAtomVec atoms; ///< Accumulated atoms during Visual mode.
CmdAtomVec atoms; ///< Accumulated subatoms. A void session collects them as the `lhs` label.
VatomState state;
} vatom;
@@ -287,10 +291,9 @@ static Dict atom_dict(const CmdAtom *atom)
PUT(d, "count", INTEGER_OBJ(spec->count));
}
// keys/lhs are RAW bytes (typeahead encoding).
PUT(d, "keys", CSTR_TO_OBJ(atom->keys != NULL ? atom->keys : ""));
if (atom->lhs != NULL && *atom->lhs != NUL) {
PUT(d, "lhs", CSTR_TO_OBJ(atom->lhs));
}
const char *keys = atom->keys != NULL ? atom->keys : "";
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));
}
@@ -339,9 +342,9 @@ 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 (Visual.active && atom_visual_replayable()) {
if (atom_visual_pending()) {
// Collecting the Visual composite: subatom of the pending visual atom.
if (vatom.state == kVatomTyped) {
if (vatom.state & kVatomTyped) {
// Not for kVatomFed: redo-prep must not mark the enclosing span as captured.
atom_captures++;
}
@@ -410,7 +413,9 @@ static void atom_stage_flush(CmdFrame *frame)
return;
}
frame->staged.changed = buf_get_changedtick(curbuf) != frame->tick;
atom_push(true, frame->staged); // Staged commands are always edits (cascadable).
// 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 };
}
@@ -447,7 +452,7 @@ static void atom_composite_start(const char *lhs, size_t len)
composite.tick = buf_get_changedtick(curbuf);
}
/// Collapses the collected subatoms (`CmdAtom.atoms`) and emits the composite atom.
/// Emits the composite atom with its collected subatoms (`CmdAtom.atoms`).
///
/// :nnoremap gj i<C-J><Esc>k$
/// "gj" => CmdAtom{ .lhs="gj", .keys="1i<NL><Esc>k$", kAMapping }
@@ -510,10 +515,10 @@ void atom_suppress(bool suppress)
atom_suppressed = suppress;
}
/// Block atom pushes if: cascade in-progress, internal op is executing, or vatom is voided.
/// Block atom pushes if: cascade in-progress, or internal op is executing.
static bool atom_blocked(void)
{
return mc_replaying() || atom_suppressed || (vatom.state == kVatomVoid && Visual.active);
return mc_replaying() || atom_suppressed;
}
/// Decides if the command is capturable.
@@ -704,6 +709,18 @@ void atom_macro_start(int regname)
}
}
/// Starts accumulating a composite for a command that stuffs its "translation" ("x" => "dl").
void atom_stuff_start(const cmdarg_T *cap)
{
// Not while another composite collects: a mapping's own label wins ("nnoremap <F6> xw").
if (!atom_has_consumers() || mc_replaying() || atom_composite_active() || !atom_is_user_input()) {
return;
}
char *lhs = atom_redo_keys(atom_cmd_spec(cap));
atom_composite_start(lhs, strlen(lhs));
xfree(lhs);
}
/// Starts accumulating a composite for a mapping resolved from typed keys (vgetorpeek()).
void atom_map_start(const char *lhs, size_t len)
{
@@ -723,10 +740,16 @@ static void atom_visual_reset(void)
atoms_free(&vatom.atoms);
}
/// True if the pending visual atom is replayable (accumulating, not voided).
/// Visual atom is pending. A void session still accumulates, for the `lhs` label.
static bool atom_visual_pending(void)
{
return vatom.state != kVatomNone;
}
/// Pending Visual atom is replayable (not voided).
bool atom_visual_replayable(void)
{
return vatom.state == kVatomTyped || vatom.state == kVatomFed;
return atom_visual_pending() && !(vatom.state & kVatomVoid);
}
/// The pending visual atom's accumulated keys (allocated), or NULL data if none is replayable
@@ -739,18 +762,15 @@ String atom_visual_span(void)
return atoms_concat_keys(vatom.atoms);
}
/// Ends the pending visual atom, appends `suffix`, and stages it. Or discards it if selection is
/// unreplayable (void/absent).
/// Ends the pending visual atom, appends `suffix`, and stages it. Or discards if unreplayable.
///
/// @param suffix Owned.
/// @param spec The completing operator, or NULL.
/// @param redoable Prep redo so "." re-executes the selection. Unreplayable (void/absent)
/// selection preps "1v" + operator instead (Vim's fixed-size visual-repeat).
/// @param redoable Prep redo. Unreplayable selection preps "1v" + op (fixed-size visual-repeat).
/// @return True if the redo was prepped.
static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoable)
{
if (atom_suppressed) {
// Replay, or internal operator applied as part of another command.
if (mc_replaying() || atom_suppressed) { // Replay, or internal op applied during another cmd.
xfree(suffix);
return false;
}
@@ -761,8 +781,22 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa
prep_redo_visual("1v", 2, (CmdSpec){ 0 }); // Equal-size fallback.
redo_append_str(suffix, -1);
}
// A poisoned selection is still a user action: emit it with lhs + empty keys, like a mapping
// whose commands captured nothing (atom_composite_end()).
bool emit = (vatom.state & kVatomVoid) && (vatom.state & kVatomTyped) && spec != NULL
&& suffix != NULL && atom_is_user_cmd();
char *label = NULL;
if (emit) {
String collected = atoms_concat_keys(vatom.atoms);
label = xrealloc(collected.data, collected.size + strlen(suffix) + 1);
STRCPY(label + collected.size, suffix);
}
xfree(suffix);
atom_visual_reset();
atom_visual_reset(); // End the session before staging.
if (emit) {
atom_stage_set((CmdAtom){ .type = kAVisual, .spec = *spec, .keys = xstrdup(""),
.lhs = label });
}
return prepped;
}
String v = atoms_concat_keys(vatom.atoms);
@@ -774,7 +808,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa
prep_redo_visual(vkeys, prefix, (CmdSpec){ 0 });
redo_append_str(suffix, -1);
}
if (!atom_is_user_cmd() || vatom.state != kVatomTyped) {
if (!atom_is_user_cmd() || !(vatom.state & kVatomTyped)) {
// Not user input (":normal! vjd", fed keys): the redo prep above is the only effect; no emit.
xfree(vkeys);
xfree(suffix);
@@ -804,7 +838,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa
/// Ends the pending visual atom with the operator `spec` ("viwee" + "x").
///
/// @return True if the redo was prepped.
/// @return True if redo was prepped.
bool atom_visual_end(CmdSpec spec, bool redoable)
{
return atom_visual_end_suffix(atom_redo_keys(spec), &spec, redoable);
@@ -939,8 +973,8 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock)
.vis = vis,
.tick = buf_get_changedtick(curbuf),
};
if (vis != kVInsNone) {
if (vis == kVInsKeys && vatom.state != kVatomTyped) {
if (vis != kVInsNone && !mc_replaying()) {
if (vis == kVInsKeys && !(atom_visual_replayable() && (vatom.state & kVatomTyped))) {
// The selection came from fed keys (":norm", scheduled feedkeys).
session.typed = false;
}
@@ -1012,6 +1046,10 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
if (mc_replaying() || atom_suppressed) {
return;
}
//
// Classify
//
const bool user = atom_is_user_cmd();
const unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar);
// Opaque cmd that changed nothing is invisible; one that changed the buffer/selection voids the
@@ -1044,6 +1082,10 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
&& buf_get_changedtick(curbuf) != old->tick) || ins_cascaded)) {
map_edit = true;
}
//
// Visual session: open/continue/close, and decide if this command is one of its subatoms.
//
bool vis = false;
if (Visual.active) {
if (!old->visual.active) {
@@ -1051,17 +1093,16 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
// Decided once, at session start.
vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed;
}
// Collecting the Visual composite: gated on the session (not atom_capturable()), so fed
// selections (":normal! vjd") still accumulate for redo-prep.
vis = atom_visual_replayable();
if (vis && (Visual.select || (ca->cmdchar == 'g' && ca->nchar == 'v'))) {
// Not replayable: Select-mode input; "gv" (absolute region).
vatom.state = kVatomVoid;
vis = false;
}
if (vis && (ca->cmdchar == 'Q' || ca->cmdchar == 'q')) {
// Recording/replay commands are meta (not part of the edit): skip, no void.
vis = false;
// 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).
vis = atom_visual_pending() && ca->cmdchar != 'Q' && ca->cmdchar != 'q';
if (vis
&& (Visual.select
|| (ca->cmdchar == 'g' && ca->nchar == 'v')
|| ((keycls & (kKeyScrollMove | kKeyScrollView | kKeyMouse)) && !unchanged))) {
// Not replayable: Select-mode input; "gv" (absolute region); the selection moved by
// viewport-dependent keys.
vatom.state |= kVatomVoid;
}
} else if (old->visual.active) {
if (user && old->follow && atom_visual_replayable() && kv_size(vatom.atoms) > 0) {
@@ -1073,6 +1114,10 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
atom_visual_reset();
}
}
//
// Capture: does this command own an atom?
//
if ((vis && atom_captures == old->captures && ca->oap->op_type == OP_NOP)
|| (!Visual.active
&& !old->visual.active
@@ -1104,7 +1149,11 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
&& strchr("/?:!Qq", ca->cmdchar) == NULL)
|| special_motion);
// Mapping-internal motions are part of its recipe: queue them, the clock edge decides.
bool follow = mapped && motion;
bool follow = (mc_following() || mapped) && motion;
//
// Route: decide the atom type and push it.
//
if (curcmd.redo_pending && !mouse_cmd) {
// Not for mouse commands (middle-click paste): pasting at every cursor would use
// viewport-dependent positions.
@@ -1148,8 +1197,8 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kACommand, spec);
atom.changed = changed;
atom_push(follow, atom);
} else if (!vis && (scroll_cmd || mouse_cmd) && !atom_composite_active()) {
// Emit-only (viewport-dependent), and never a subatom. Composite keys must stay replayable.
} else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) {
// Emit-only (viewport-dependent).
CmdSpec spec = atom_cmd_spec(ca);
if (IS_SPECIAL(ca->cmdchar)) {
// Wheel/mouse count is never typed: do_mousescroll() wrote its internal step there.
@@ -1161,12 +1210,12 @@ static void atom_capture_cmd(cmdarg_T *ca, const CmdFrame *old, bool toplevel)
}
if (vis && kv_size(vatom.atoms) == collected && !unchanged) {
// Not replayable: moved the selection by non-collectible keys.
vatom.state = kVatomVoid;
vatom.state |= kVatomVoid;
}
}
if (vis && (curbuf != old->buf || buf_get_changedtick(curbuf) != old->tick)) {
// Not replayable: edited buffer during selection, so the keys do not describe the change.
vatom.state = kVatomVoid;
vatom.state |= kVatomVoid;
}
if (Visual.active && user && toplevel) {
mc_vsel_refresh();

View File

@@ -55,8 +55,8 @@ struct CmdAtom {
char *keys; ///< Resolved keysequence (typeahead encoding), including `["x][count]` prefix
///< (unlike `CmdSpec.body`, the raw unprefixed form).
char *text; ///< Payload: insert-session text, or Ex/search cmdline.
char *lhs; ///< Mapping LHS or macro register ("gj", "@q") that produced this atom, or NULL.
///< Label/hint, not replayed.
char *lhs; ///< Unresolved user input: mapping LHS or macro register ("@q"), or Visual op.
///< Label/hint, not replayed. NULL: untranslated, same as `keys`.
CmdAtomType type;
bool changed; ///< The command changed the buffer.
bool remap; ///< Replay `keys` w/ remap. For replay of a payload mapping (vim-surround "ds'"),

View File

@@ -4877,6 +4877,7 @@ static void nv_optrans(cmdarg_T *cap)
static const char *str = "xXDCsSY&";
if (!checkclearopq(cap->oap)) {
atom_stuff_start(cap);
if (cap->count0) {
stuffnumReadbuff(cap->count0);
}
@@ -5769,6 +5770,7 @@ static void nv_dot(cmdarg_T *cap)
// If "restart_edit" is true, the last but one command is repeated
// instead of the last command (inserting text). This is used for
// CTRL-O <.> in insert mode.
atom_stuff_start(cap);
if (start_redo(cap->count0, restart_edit != 0 && Ins.moved == kInsNone) == false) {
clearopbeep(cap->oap);
}

View File

@@ -57,7 +57,7 @@ end)
describe('CmdAtom', function()
before_each(clear)
it('a counted mapped motion carries its count in the atom', function()
it('motion mapping', function()
command('nnoremap j gj')
fn.setline(1, { 'a1', 'b2', 'c3', 'd4', 'e5' })
feed('gg')
@@ -80,11 +80,17 @@ describe('CmdAtom', function()
feed('gg0')
feed('3<F6>')
ev = atom_last()
eq({ type = 'mapping' }, pick(ev, 'type', 'count'))
eq({ keys = '3dl', count = 3 }, pick(ev.atoms[1], 'keys', 'count'))
eq({ type = 'mapping', lhs = k('<F6>') }, pick(ev, 'type', 'count', 'lhs'))
-- Only the composite carries the mapping's LHS: a subatom is its own input.
eq({ keys = '3dl', count = 3, lhs = '3dl' }, pick(ev.atoms[1], 'keys', 'count', 'lhs'))
-- "." repeats the mapping's EDIT as ONE atom, labeled "." (not "<F6>").
local before = #atoms()
feed('.')
eq(before + 1, #atoms())
eq({ type = 'operator', keys = '3dl', lhs = '.' }, pick(atom_last(), 'type', 'keys', 'lhs'))
end)
it('a Lua-callback mapping (the "]q" default) emits a mapping atom', function()
it('Lua-callback mapping (e.g. "]q" default)', function()
-- Same shape as the "]q" default mapping: a Lua callback with no
-- replayable keys. Still a user action: it publishes with an empty
-- replay payload.
@@ -112,7 +118,7 @@ describe('CmdAtom', function()
end)
]])
feed(',e')
eq({ keys = '', changed = true }, pick(atom_last(), 'keys', 'changed'))
eq({ keys = '', 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>')
@@ -371,7 +377,12 @@ describe('CmdAtom', function()
n.poke_eventloop()
eq(2, fn.line('.')) -- the scroll dragged the cursor: selection is lines 1-2
feed('d')
eq(before, #atoms()) -- not replayable: no atom published for the edit
-- Publishes with empty `CmdAtom.keys`; the keys that produced it are in `lhs`.
eq(before + 1, #atoms())
eq(
{ type = 'visual', keys = '', lhs = k('V<C-E>d'), changed = true },
pick(atom_last(), 'type', 'keys', 'lhs', 'changed')
)
eq('l3', fn.getline(1)) -- the edit itself deleted both selected lines
-- "." on the unreplayable operation falls back to an equal-size reselect
@@ -379,6 +390,28 @@ describe('CmdAtom', function()
feed('.')
eq('l5', fn.getline(1))
-- But a viewport key that preserves cursor ("zz") does not move the selection: still
-- replayable, and collected like any other subatom.
fn.setline(1, lines)
feed('gg')
before = #atoms()
feed('Vzzd')
eq('l2', fn.getline(1))
eq(before + 1, #atoms())
-- Nothing was translated, so lhs=keys.
eq({ type = 'visual', keys = 'Vzzd', lhs = 'Vzzd' }, pick(atom_last(), 'type', 'keys', 'lhs'))
feed('.')
eq('l3', fn.getline(1))
-- "gv" (absolute region) is unreplayable, but emitted in `lhs`.
api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa bbb' })
feed('gg0viw<Esc>')
before = #atoms()
feed('gvd')
eq(' bbb', fn.getline(1))
eq(before + 1, #atoms())
eq({ type = 'visual', keys = '', lhs = 'gvd' }, pick(atom_last(), 'type', 'keys', 'lhs'))
-- A fed (":normal!") Visual-put preps the selection keysequence, like any fed visual
-- operator (":normal! vjd"): "." re-executes "Vjp", not a bare "p".
api.nvim_buf_set_lines(0, 0, -1, true, { 'aa', 'bb', 'cc', 'dd', 'ee' })
@@ -526,14 +559,15 @@ describe('CmdAtom', function()
end)
end)
it('one event per operation, for each kind of atom', 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)
local function atom(keys, expected, lhs)
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)
end
local lines = {}
for i = 1, 20 do
@@ -542,10 +576,17 @@ describe('CmdAtom', function()
fn.setline(1, lines)
feed('gg0')
atoms_start()
-- "." with nothing to repeat stuffs nothing.
feed('.')
eq({ { type = 'command', 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')
atom('3x', '3dl')
atom('x', 'dl', 'x')
-- A stuffed translation UNWRAPS: keeps the resolved "type" and has no subatoms.
-- Only `lhs` marks it as translated; it is not a composite.
eq({ type = 'operator' }, pick(atom_last(), 'type', 'atoms'))
atom('3x', '3dl', '3x')
atom('D', 'd$', 'D')
atom('dw', 'dw')
atom('"z2dw', '"z2dw')
atom('yy', 'yy')
@@ -570,14 +611,33 @@ describe('CmdAtom', function()
atom('viwd', 'viwd')
atom('Vd', 'Vd')
atom('<C-v>jd', '<C-V>jd')
-- Motions.
-- Motions: the target is relative to the cursor.
atom('w', 'w')
atom('3w', '3w')
atom('fb', 'fb')
atom('G', 'G')
atom('$', '$')
feed('gg0')
atom(']]', ']]')
-- "%" is cursor-relative.
command('silent! nunmap %') -- the bundled matchit plugin maps it
fn.setline(1, 'alpha (beta) gamma')
feed('gg0f(')
atom('%', '%')
eq('motion', atom_last().type)
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)
-- Jumps: absolute/shared-state navigation, their own kind.
atom('ma', 'ma')
eq('command', atom_last().type) -- "m" sets state; it does not jump
@@ -592,9 +652,16 @@ describe('CmdAtom', function()
atom('<C-r>', '<C-R>')
-- "." emits its resolution (like "x" => "dl").
feed('gg0')
atom('x', 'dl')
atom('.', 'dl')
atom('3.', '3dl') -- "3.": the new count replaces the captured one
atom('x', 'dl', 'x')
atom('.', 'dl', '.')
atom('3.', '3dl', '3.') -- "3.": the new count replaces the captured one
-- "." emits exactly ONE atom labeled ".": the repeated keys fold into its composite.
atom('iQ<Esc>', '1iQ<Esc>')
atom('.', '1iQ<Esc>', '.')
atom('viwd', 'viwd')
atom('.', 'viwd', '.')
atom('cwZZ<Esc>', 'cwZZ<Esc>')
atom('.', 'cwZZ<Esc>', '.')
-- Payload commands: the interactively-typed cmdline completes the
-- keysequence (not a bare "/" or ":" prefix).
atom('/beta<CR>', '/beta<NL>')
@@ -630,13 +697,11 @@ describe('CmdAtom', function()
local count = #atoms()
feed(',E') -- E492 mid-mapping: the trailing "x" never runs
eq(count, #atoms())
atom('x', 'dl') -- the next command is not folded into the dead composite
eq(nil, atom_last().lhs) -- not from a mapping: omitted
atom('x', 'dl', 'x') -- the next command is not folded into the dead composite
command('nmap ,A ,B')
command('nmap ,B ,A')
feed(',A') -- E223: recursive mapping
atom('x', 'dl')
eq(nil, atom_last().lhs) -- not from a mapping: omitted
atom('x', 'dl', 'x')
-- Macro playback ("@q") emits its commands' atoms (capture-on-replay);
-- "@q" itself is a translation, never an atom.
local total = #atoms()
@@ -795,9 +860,14 @@ 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.
eq({ 'g@iw"' }, atoms_tail(1))
-- 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'))
eq({ '"alpha" beta' }, get_lines())
end)