Merge #41529 from justinmk/cmdatom

This commit is contained in:
Justin M. Keyes
2026-08-29 05:17:52 -04:00
committed by GitHub
11 changed files with 146 additions and 59 deletions

View File

@@ -485,11 +485,18 @@ All the global variables are declared in `globals.h`.
THE MAIN EVENT-LOOP
The main loop is implemented in state_enter. The basic idea is that Vim waits
The main loop is implemented in state_enter. The basic idea is that Nvim waits
for the user to type a character and processes it until another character is
needed. Thus there are several places where Vim waits for a character to be
needed. Thus there are several places where Nvim waits for a character to be
typed. The `vgetc()` function is used for this. It also handles mapping.
Internal logic "stuffs" keys (readahead) only for a consumer on the current
call stack: `exec_stuffed()`, or a nested reader it invokes. See "Stuffing"
in input.c. (This is simpler and more predictable than Vim, where a command
may stuff chars, and let the main loop deal with them at some arbitrary future
point, thus requiring global flags checked at the right time, scattered
throughout the codebase.)
What we consider the "Nvim event loop" is actually a wrapper around `uv_run` to
handle both the `fast_events` queue and possibly (a suitable subset of) deferred
events. Therefore "raw" `vim.uv.run()` is often not enough to yield from Lua;

View File

@@ -4708,7 +4708,7 @@ void get_user_input(const typval_T *const argvars, typval_T *const rettv, const
}
cmdline_row = msg_row;
stuffReadbuffSpec(defstr);
stuffReadbuffSpecial(defstr);
const int save_ex_normal_busy = ex_normal_busy;
ex_normal_busy = 0;

View File

@@ -1,4 +1,4 @@
// input.c: The input engine "bytes layer" (input_cmdatom.c. is the "policy layer").
// input.c: The input engine "bytes layer" (input_cmdatom.c is the "policy layer").
//
// - Get a character from the user, a script file, or the keybufs described below.
// - Apply mappings and abbreviations to typed keys (the :map tables live in mapping.c; the
@@ -8,7 +8,12 @@
//
// Concepts:
// - "Stuffing" = when some internal logic pushes keys to execute next. This is how a cmd
// "translates" into another cmd: "x" stuffs "dl" (nv_optrans()), "." stuffs the redo buf.
// "translates" into another cmd: "x" stuffs "dl", "." stuffs the redo buf.
// - XXX NOTE: stuffed keys must have a consumer in the current codepath (not "later on the main
// loop"). There are 3 different rituals, depending on the consumer:
// 1. basic translation: stuff, then exec_stuffed(); runs as CmdFrames of the stuffing command.
// 2. input staged for a nested reader (getcmdline(), edit()): stuff only.
// 3. main-loop wakeup (K_NOP): stuffed so it precedes input (unlike an event).
// - TYPEAHEAD vs READ-AHEAD:
// - typeahead = external input that arrived faster than can be executed (user typed fast).
// - readahead = Nvim's own self-input.
@@ -18,10 +23,14 @@
// (KeyStuffed).
//
// These buffers are used:
// - stuff buffers (`readbuf1`, `readbuf2`) are readahead buffers.
// - TWO stuff buffers, because stuffing nests: a cmd executed FROM redo keys (readbuf2) may
//
// input ──► [ typebuf │ readbuf2 (redo) │ readbuf1 (stuff) ] ──► vgetc() ──► exec
// ▲ mappings expand at typebuf front
//
// - typeahead (`typebuf`).
// - readahead/stuff buffers (`readbuf1`, `readbuf2`).
// - Two stuff buffers, because stuffing nests: a cmd executed FROM redo keys (readbuf2) may
// itself stuff a translation (readbuf1), which must be consumed before the remaining redo.
// - `typebuf`: typeahead (see below).
// - `redobuff` (RedoState): the current + previous change.
// - `recordbuff`: accumulates the keys of a recording ("q").
//
@@ -750,7 +759,7 @@ void stuffReadbuffLen(const char *s, ptrdiff_t len)
/// Stuff "s" into the stuff buffer, leaving special key codes unmodified and
/// escaping other K_SPECIAL bytes.
/// Change CR, LF and ESC into a space.
void stuffReadbuffSpec(const char *s)
void stuffReadbuffSpecial(const char *s)
FUNC_ATTR_NONNULL_ALL
{
while (*s != NUL) {

View File

@@ -78,14 +78,12 @@ static uint64_t frame_id = 0;
/// The executing command's frame; its `parent` chain spans nested `normal_execute()`.
static CmdFrame *cur_frame = NULL;
/// The pending atom, while it spans CmdFrames (a mapping's commands, a stuffed continuation, an
/// operator awaiting its motion). See `vatom` for Visual composite.
/// The pending atom, while it spans CmdFrames (a mapping's commands, an operator awaiting its
/// motion, i_CTRL-O). See `vatom` for Visual composite.
///
/// Also used by undo, to restore cursor position.
static struct {
bool open; ///< True from the atom's first toplevel frame until it resolves.
bool stuffed; ///< Last frame had stuffed keys pending (next frame continues the atom).
bool redo; ///< The continuation completes the prepped redo ("!ip" stuffs ":.,.+1!").
CmdOrigin origin; ///< State at the atom's start.
CmdAtomVec atoms; ///< Subatoms of the mapping/macro.
char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting).
@@ -235,14 +233,6 @@ pos_T atom_origin_pos(buf_T *buf)
return curwin->w_cursor;
}
/// Decides the "origin" state. Usually `frame->origin`, except for "stuffed" cases.
/// A "stuffed" continuation belongs to its stuffing frame (`x` stuffs `dl`: the `dl` atom's origin
/// is at `x`).
static CmdOrigin frame_origin(const CmdFrame *frame)
{
return frame->cont ? composite.origin : frame->origin;
}
/// Composes a CmdSpec into `redo_keys` format.
/// @return Allocated key sequence.
static char *atom_redo_keys(CmdSpec spec)
@@ -822,10 +812,11 @@ void atom_typed_del(size_t len)
kv_size(typed.keys) -= MIN(len, kv_size(typed.keys));
}
/// Discards the redo-atom: new or invalid command. Only at toplevel (not nested ":normal!").
/// Discards the redo-atom: new or invalid command. Only at toplevel: a nested frame (":normal!",
/// an exec_stuffed() drain) must not disturb the enclosing command's.
static void atom_redo_reset(void)
{
if (!atom_is_user_cmd()) {
if (!atom_is_user_cmd() || (cur_frame != NULL && cur_frame->parent != NULL)) {
return;
}
curcmd.redo_frame = 0;
@@ -875,7 +866,7 @@ void atom_macro_start(int regname)
}
}
/// Starts accumulating a composite for a command that stuffs its "translation" ("x" => "dl").
/// Starts accumulating composite for a cmd that stuffs its translation ("x" => "dl", "." => redo).
void atom_stuff_start(const cmdarg_T *cap)
{
// Not while another composite collects: a mapping's own label wins ("nnoremap <F6> xw").
@@ -1017,7 +1008,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa
};
if (spec != NULL) {
kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix,
.origin = frame_origin(cur_frame) }));
.origin = cur_frame->origin }));
} else {
xfree(suffix);
}
@@ -1068,7 +1059,7 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank)
spec.cmd2 = operand ? NUL : cap->nchar;
spec.cmdarg = operand ? cap->nchar : NUL;
CmdAtom op_atom = atom_from_spec(kAOperator, spec);
op_atom.origin = frame_origin(cur_frame);
op_atom.origin = cur_frame->origin;
atom_stage_set(&op_atom);
}
} else if (!Visual.active || oap->motion_force) {
@@ -1169,7 +1160,7 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock)
// 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 ? frame_origin(cur_frame) : atom_origin(),
: cur_frame != NULL ? cur_frame->origin : atom_origin(),
};
if (vis != kVInsNone && !mc_replaying()) {
if (vis == kVInsKeys && !(atom_visual_replayable() && (vatom.state & kVatomTyped))) {
@@ -1226,14 +1217,9 @@ static void atom_ins_push(const InsSession *session, bool cascade)
/// Toplevel entry: starts a new atom. Samples the pre-cmd state (`origin`); pushes the frame.
void atom_cmd_start(CmdFrame *old)
{
// A stuffed continuation frame is one cmd with its stuffing frame: `v2e".p` stuffs `c…`, `!ip`
// stuffs `:.,.+1!`. KeyStuffed=false if error/CTRL-C aborted, `composite.stuffed` is then stale.
old->cont = KeyStuffed && cur_frame == NULL && composite.open && composite.stuffed;
old->origin = atom_origin();
if (cur_frame == NULL && !composite.open) {
composite.open = true;
composite.stuffed = false;
composite.redo = false;
composite.origin = old->origin;
}
old->visual = Visual;
@@ -1251,11 +1237,7 @@ void atom_cmd_start(CmdFrame *old)
old->parent = cur_frame;
cur_frame = old;
curcmd.op_global = false;
if (old->cont && composite.redo) {
curcmd.redo_frame = old->id; // The continuation completes the prepped redo.
} else {
atom_redo_reset();
}
atom_redo_reset();
}
/// Captures the typed command's atom: one atom per command, produced from the CmdFrame diff and
@@ -1313,7 +1295,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
// Decided once, at session start.
vatom.state = (old->keytyped || (atom_composite_active() && atom_is_user_cmd()))
? kVatomTyped : kVatomFed;
vatom.origin = frame_origin(old);
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).
@@ -1382,7 +1364,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
// 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.origin = frame_origin(old);
atom.origin = old->origin;
atom_push(effect, &atom);
} else {
atom_free(&atom);
@@ -1391,14 +1373,14 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
&& !(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.origin = frame_origin(old);
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(kAExcmd, ca, curcmd.cmdline);
// Payload read during the cmdline execution (`ds)` getchar() => ")").
atom_payload_append(&atom, old);
atom.origin = frame_origin(old);
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.
@@ -1408,7 +1390,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
spec.regname = 0;
}
CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kANormal, spec);
atom.origin = frame_origin(old);
atom.origin = old->origin;
atom_push(follow, &atom);
} else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) {
// Emit-only (viewport-dependent).
@@ -1418,7 +1400,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
spec.count = 0;
}
CmdAtom atom = atom_from_spec(scroll_cmd ? kAScroll : kAMouse, spec);
atom.origin = frame_origin(old);
atom.origin = old->origin;
atom_push(false, &atom);
}
if (vis && kv_size(vatom.atoms) == collected && !unchanged) {
@@ -1448,8 +1430,9 @@ void atom_cmd_end(cmdarg_T *ca, CmdFrame *old)
}
// 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").
// Deferred while a mapping executes (keys in typebuf), so its commands collapse as one unit;
// likewise while stuffed keys are pending (i_CTRL-O dance, or exec_stuffed() deferred under
// textlock).
if (old->parent == NULL && !mc_replaying() && typebuf_typed() && stuff_empty()) {
mc_clock_edge(map_edit);
map_edit = false;
@@ -1460,13 +1443,5 @@ void atom_cmd_end(cmdarg_T *ca, CmdFrame *old)
composite.open = false; // Resolved.
}
}
if (old->parent == NULL) {
// Stuffed keys pending: the next frame continues the atom.
composite.stuffed = !stuff_empty();
// The continuation also completes the redo-prep.
// Except Visual: stuffing is captured in its payload.
composite.redo = composite.stuffed && curcmd.redo_frame == old->id
&& !Visual.active && !old->visual.active;
}
cur_frame = old->parent;
}

View File

@@ -17,7 +17,6 @@ extern CmdAtomVec g_atoms;
typedef struct CmdFrame CmdFrame;
struct CmdFrame {
CmdOrigin origin; ///< State at entry.
bool cont; ///< Stuffed continuation frame: the atom began earlier.
VisualState visual; ///< Visual-mode state (active/start/mode are diffed).
bool keytyped; ///< KeyTyped
uint64_t captures; ///< Capture counter.

View File

@@ -492,6 +492,7 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent)
stuffcharReadbuff('y');
stuffcharReadbuff(K_MIDDLEMOUSE);
}
exec_stuffed(oap);
return false;
}
// The rest is below jump_to_mouse()

View File

@@ -4561,6 +4561,9 @@ static void nv_replace(cmdarg_T *cap)
stuffcharReadbuff('R');
stuffcharReadbuff('\t');
stuffcharReadbuff(ESC);
if (exec_stuffed(cap->oap)) {
cap->retval |= CA_COMMAND_BUSY;
}
return;
}
@@ -4868,7 +4871,7 @@ static void nv_abbrev(cmdarg_T *cap)
}
}
/// Translate a command into another command.
/// Translate a command into another command, and execute it.
static void nv_optrans(cmdarg_T *cap)
{
static const char *(ar[]) = { "dl", "dh", "d$", "c$", "cl", "cc", "yy",
@@ -4881,6 +4884,9 @@ static void nv_optrans(cmdarg_T *cap)
stuffnumReadbuff(cap->count0);
}
stuffReadbuff(ar[strchr(str, (char)cap->cmdchar) - str]);
if (exec_stuffed(cap->oap)) {
cap->retval |= CA_COMMAND_BUSY;
}
}
cap->opcount = 0;
}
@@ -5650,6 +5656,7 @@ static void nv_g_cmd(cmdarg_T *cap)
case K_LEFTMOUSE:
if (do_mouse(oap, cap->nchar, BACKWARD, cap->count1, 0)) {
stuffcharReadbuff(Ctrl_RSB);
exec_stuffed(oap);
}
break;
@@ -5772,6 +5779,11 @@ static void nv_dot(cmdarg_T *cap)
atom_stuff_start(cap);
if (start_redo(cap->count0, restart_edit != 0 && Ins.moved == kInsNone) == false) {
clearopbeep(cap->oap);
return;
}
// Execute the redo keys here: the whole replay resolves within this "." command.
if (exec_stuffed(cap->oap)) {
cap->retval |= CA_COMMAND_BUSY;
}
}
@@ -6699,6 +6711,12 @@ static void nv_event(cmdarg_T *cap)
}
}
/// Executes one normal-mode command from pending input, outside the main state machine.
///
/// Called in a loop; a count/register prefix or a pending operator travels into the next call via
/// `oap` ("3dl" is three calls, one command).
///
/// @param toplevel `NormalState.toplevel` (full interactive-command treatment).
void normal_cmd(oparg_T *oap, bool toplevel)
{
NormalState s;
@@ -6709,3 +6727,31 @@ void normal_cmd(oparg_T *oap, bool toplevel)
normal_execute(&s.state, safe_vgetc());
*oap = s.oa;
}
/// Executes the pending readahead (see "Stuffing", input.c).
///
/// During "textlock" the stuffed keys are left for the main loop instead (for CmdAtom/multicursor
/// purposes, that's fine: if stuff_empty()=false, the pending CmdAtom stays open and will collect
/// the effects later).
///
/// @param oap The continuing operator state (see `normal_cmd`), or NULL.
/// @return True if insert-session-resume is pending (i_CTRL-O): the caller reports
/// CA_COMMAND_BUSY, so resume happens after next command instead.
bool exec_stuffed(oparg_T *oap)
{
if (text_locked() || curbuf_locked()) {
return false;
}
oparg_T oa;
clear_oparg(&oa);
if (oap == NULL) {
oap = &oa;
}
finish_op = false;
while (!stuff_empty() && !got_int) {
update_topline_cursor();
normal_cmd(oap, true);
}
finish_op = false;
return restart_edit != 0;
}

View File

@@ -31,6 +31,7 @@
#include "nvim/eval/typval_defs.h"
#include "nvim/ex_cmds2.h"
#include "nvim/ex_cmds_defs.h"
#include "nvim/ex_docmd.h"
#include "nvim/ex_getln.h"
#include "nvim/extmark.h"
#include "nvim/file_search.h"
@@ -3073,10 +3074,10 @@ void cursor_pos_info(dict_T *dict)
}
}
/// Handle indent and format operators and visual mode ":".
/// Handle indent, format operators and visual mode ":". Stuff the ":[range]…" cmdline and run it
/// here. For OP_COLON/OP_FILTER the user types the rest after the stuffed part.
static void op_colon(oparg_T *oap)
{
stuffcharReadbuff(':');
if (oap->is_VIsual) {
stuffReadbuff("'<,'>");
} else {
@@ -3126,7 +3127,11 @@ static void op_colon(oparg_T *oap)
stuffReadbuff("\n']");
}
// do_cmdline() does the rest
do_cmdline(NULL, getexline, NULL, 0);
// Keys stuffed past the cmdline ("']" for OP_FORMAT): internal cleanup, not an atom.
atom_suppress(true);
exec_stuffed(NULL);
atom_suppress(false);
}
#ifdef EXITFREE

View File

@@ -14,6 +14,7 @@
#include "nvim/eval.h"
#include "nvim/eval/typval.h"
#include "nvim/ex_cmds2.h"
#include "nvim/ex_docmd.h"
#include "nvim/ex_getln.h"
#include "nvim/extmark.h"
#include "nvim/file_search.h"
@@ -1525,6 +1526,9 @@ void do_put(int regname, yankreg_T *reg, int dir, int count, int flags)
return;
}
}
// Execute the stuffed keys here: the put resolves within the current command.
exec_stuffed(NULL);
return;
}

View File

@@ -320,7 +320,7 @@ describe('CmdAtom', function()
)
end)
it('"!" operator captures its stuffed cmdline continuation', function()
it('"!" operator captures its stuffed cmdline', function()
-- ":.,.+1!" + typed "{prg}<CR>" completes the operator atom, like "d/END<CR>". #41447
local prg = n.testprg('shell-test') .. ' REP 2 X'
fn.setline(1, { 'b', 'a', '', 'e', 'c', 'd' })
@@ -358,6 +358,43 @@ describe('CmdAtom', function()
)
end)
it('stuffed translations execute within their command (exec_stuffed)', function()
-- A register prefix crosses into the "." replay; an explicit register must not touch the
-- small-delete register.
fn.setline(1, { 'aaa bbb', 'ccc ddd' })
feed('gg0dw')
eq('aaa ', fn.getreg('-'))
feed('j0"z.')
eq('ccc ', fn.getreg('z'))
eq('aaa ', fn.getreg('-'))
eq({ 'bbb', 'ddd' }, get_lines())
-- i_CTRL-O inside a stuffed translation's insert session ("S" == "cc"): the session resumes
-- after ONE normal command.
api.nvim_buf_set_lines(0, 0, -1, true, { 'xxxx', 'yyyy' })
feed('ggSabc<C-o>0def<Esc>')
eq({ 'defabc', 'yyyy' }, get_lines())
-- r<Tab> under 'expandtab'/'smarttab' ("{count}R<Tab><Esc>": edit() does the tab).
command('set expandtab shiftwidth=4 tabstop=4')
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh', 'ABCDEFGH' })
feed('gg03r<Tab>')
eq(' defgh', fn.getline(1))
feed('j0.')
eq(' DEFGH', fn.getline(2))
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh' })
feed('gg0r<C-v><Tab>')
eq('\tbcdefgh', fn.getline(1))
command('set noexpandtab smarttab shiftwidth=4 tabstop=8')
api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh' })
feed('gg02r<Tab>')
eq('\tcdefgh', fn.getline(1))
command('set expandtab& smarttab& shiftwidth& tabstop&')
-- "&" and "count&" (":.,.+Ns").
api.nvim_buf_set_lines(0, 0, -1, true, { 'blue a', 'blue b', 'blue c', 'blue d' })
command('1s/blue/red/')
feed('2G&3G2&')
eq({ 'red a', 'red b', 'red c', 'red d' }, get_lines())
end)
it('mapping that enters :terminal mode', function()
-- Fake picker/fuzzy-finder: a mapping that opens a :terminal UI (like fzf-lua).
-- Its composite ends when :terminal is entered.

View File

@@ -174,6 +174,10 @@ describe('TUI :detach', function()
nvim_set .. ' laststatus=2 background=dark',
}, { env = env_notermguicolors, cols = opts.cols })
tt.override_screen_expect_for_conpty(screen)
-- The child's `--listen` socket is created asynchronously wrt its PTY output.
t.retry(nil, 2000, function()
assert(vim.uv.fs_stat(child_server))
end)
end
it('does not stop server', function()