feat(events): MarkSet event, aucmd_defer() #35793

Problem:
- Can't subscribe to "mark" events.
- Executing events is risky because they can't be deferred.

Solution:
- Introduce `MarkSet` event.
- Introduce `aucmd_defer()`.

Helped-by: zeertzjq <zeertzjq@outlook.com>
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
This commit is contained in:
Nathan Smith
2025-12-07 12:13:31 -08:00
committed by GitHub
parent 64cf63a881
commit 551bb63d44
14 changed files with 448 additions and 4 deletions

View File

@@ -759,6 +759,19 @@ LspNotify See |LspNotify|
LspProgress See |LspProgress| LspProgress See |LspProgress|
LspRequest See |LspRequest| LspRequest See |LspRequest|
LspTokenUpdate See |LspTokenUpdate| LspTokenUpdate See |LspTokenUpdate|
*MarkSet*
MarkSet After a |mark| is set by |m|, |:mark|, and
|nvim_buf_set_mark()|. Supports `[a-zA-Z]`
marks (may support more in the future).
The |autocmd-pattern| is matched against the
mark name (e.g. `[ab]` matches `a` or `b`, `*`
matches all).
The |event-data| has these keys:
- name: Mark name (e.g. "a")
- line: Mark line.
- col: Mark column.
*MenuPopup* *MenuPopup*
MenuPopup Just before showing the popup menu (under the MenuPopup Just before showing the popup menu (under the
right mouse button). Useful for adjusting the right mouse button). Useful for adjusting the

View File

@@ -60,9 +60,9 @@ the Nvim editor. (There is an unrelated, low-level concept defined by the
`event/defs.h#Event` struct, which is just a bag of data passed along the `event/defs.h#Event` struct, which is just a bag of data passed along the
internal |event-loop|.) internal |event-loop|.)
All new editor events must be implemented using `aucmd_defer()` (and where Where possible, new editor events should be implemented using `aucmd_defer()`
possible, old events should be migrated to this), so that they are processed (and where possible, old events migrate to this), so they are processed in
in a predictable manner, which avoids crashes and race conditions. See a predictable manner, which avoids crashes and race conditions. See
`do_markset_autocmd` for an example. `do_markset_autocmd` for an example.
============================================================================== ==============================================================================

View File

@@ -226,6 +226,8 @@ EVENTS
• New `msg_id` parameter for |ui-messages| `msg_show` event. • New `msg_id` parameter for |ui-messages| `msg_show` event.
• 'rulerformat' is emitted as `msg_ruler` when not part of the statusline. • 'rulerformat' is emitted as `msg_ruler` when not part of the statusline.
• Creating or updating a progress message with |nvim_echo()| triggers a |Progress| event. • Creating or updating a progress message with |nvim_echo()| triggers a |Progress| event.
• |MarkSet| is triggered after a |mark| is set by the user (currently doesn't
support implicit marks like |'[| or |'<|, …).
HIGHLIGHTS HIGHLIGHTS

View File

@@ -2535,6 +2535,7 @@ A jump table for the options with a short description can be found at |Q_op|.
|LspProgress|, |LspProgress|,
|LspRequest|, |LspRequest|,
|LspTokenUpdate|, |LspTokenUpdate|,
|MarkSet|,
|MenuPopup|, |MenuPopup|,
|ModeChanged|, |ModeChanged|,
|OptionSet|, |OptionSet|,

View File

@@ -160,6 +160,7 @@ error('Cannot require a meta file')
--- |'LspProgress' --- |'LspProgress'
--- |'LspRequest' --- |'LspRequest'
--- |'LspTokenUpdate' --- |'LspTokenUpdate'
--- |'MarkSet'
--- |'MenuPopup' --- |'MenuPopup'
--- |'ModeChanged' --- |'ModeChanged'
--- |'OptionSet' --- |'OptionSet'

View File

@@ -2213,6 +2213,7 @@ vim.go.ei = vim.go.eventignore
--- `LspProgress`, --- `LspProgress`,
--- `LspRequest`, --- `LspRequest`,
--- `LspTokenUpdate`, --- `LspTokenUpdate`,
--- `MarkSet`,
--- `MenuPopup`, --- `MenuPopup`,
--- `ModeChanged`, --- `ModeChanged`,
--- `OptionSet`, --- `OptionSet`,

View File

@@ -81,6 +81,7 @@ return {
LspProgress = false, -- after a LSP progress update LspProgress = false, -- after a LSP progress update
LspRequest = false, -- after an LSP request is started, canceled, or completed LspRequest = false, -- after an LSP request is started, canceled, or completed
LspTokenUpdate = false, -- after a visible LSP token is updated LspTokenUpdate = false, -- after a visible LSP token is updated
MarkSet = false, -- after a mark is set
MenuPopup = false, -- just before popup menu is displayed MenuPopup = false, -- just before popup menu is displayed
ModeChanged = false, -- after changing the mode ModeChanged = false, -- after changing the mode
OptionSet = false, -- after setting any option OptionSet = false, -- after setting any option

View File

@@ -7,7 +7,9 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "nvim/api/private/converter.h"
#include "nvim/autocmd.h" #include "nvim/autocmd.h"
#include "nvim/autocmd_defs.h"
#include "nvim/buffer.h" #include "nvim/buffer.h"
#include "nvim/charset.h" #include "nvim/charset.h"
#include "nvim/cmdexpand_defs.h" #include "nvim/cmdexpand_defs.h"
@@ -111,6 +113,18 @@ static char *old_termresponse = NULL;
static Map(String, int) map_augroup_name_to_id = MAP_INIT; static Map(String, int) map_augroup_name_to_id = MAP_INIT;
static Map(int, String) map_augroup_id_to_name = MAP_INIT; static Map(int, String) map_augroup_id_to_name = MAP_INIT;
void autocmd_init(void)
{
deferred_events = multiqueue_new_child(main_loop.events);
}
#ifdef EXITFREE
void autocmd_free_all_mem(void)
{
multiqueue_free(deferred_events);
}
#endif
static void augroup_map_del(int id, const char *name) static void augroup_map_del(int id, const char *name)
{ {
if (name != NULL) { if (name != NULL) {
@@ -1493,6 +1507,85 @@ win_found:
} }
} }
/// Schedules an autocommand event, to be executed at the next event-loop tick.
///
/// @param event Event to schedule
/// @param fname Name to use as `<amatch>` (the "pattern"). NULL/empty means use actual filename.
/// @param fname_io Filename to use for <afile> on cmdline, NULL means use `fname`.
/// @param group Group ID or AUGROUP_ALL
/// @param buf Buffer for <abuf>
/// @param eap Ex command arguments
/// @param data Event-specific data. Will be copied, caller must free `data`.
/// The `data` items will also be copied to `v:event`.
void aucmd_defer(event_T event, char *fname, char *fname_io, int group, buf_T *buf, exarg_T *eap,
Object *data)
{
AutoCmdEvent *evdata = xmalloc(sizeof(AutoCmdEvent));
evdata->event = event;
evdata->fname = fname != NULL ? xstrdup(fname) : NULL;
evdata->fname_io = fname_io != NULL ? xstrdup(fname_io) : NULL;
evdata->group = group;
evdata->buf = buf->handle;
evdata->eap = eap;
if (data) {
evdata->data = xmalloc(sizeof(Object));
*evdata->data = copy_object(*data, NULL);
} else {
evdata->data = NULL;
}
multiqueue_put(deferred_events, deferred_event, evdata);
}
/// Executes a deferred autocommand event.
static void deferred_event(void **argv)
{
AutoCmdEvent *e = argv[0];
event_T event = e->event;
char *fname = e->fname;
char *fname_io = e->fname_io;
int group = e->group;
exarg_T *eap = e->eap;
Object *data = e->data;
Error err = ERROR_INIT;
buf_T *buf = find_buffer_by_handle(e->buf, &err);
if (buf) {
// Copy `data` to `v:event`.
save_v_event_T save_v_event;
dict_T *v_event = get_v_event(&save_v_event);
if (data && data->type == kObjectTypeDict) {
for (size_t i = 0; i < data->data.dict.size; i++) {
KeyValuePair item = data->data.dict.items[i];
typval_T tv;
object_to_vim(item.value, &tv, &err);
if (ERROR_SET(&err)) {
api_clear_error(&err);
continue;
}
tv_dict_add_tv(v_event, item.key.data, item.key.size, &tv);
tv_clear(&tv);
}
}
tv_dict_set_keys_readonly(v_event);
aco_save_T aco;
aucmd_prepbuf(&aco, buf);
apply_autocmds_group(event, fname, fname_io, false, group, buf, eap, data);
aucmd_restbuf(&aco);
restore_v_event(v_event, &save_v_event);
}
xfree(fname);
xfree(fname_io);
if (data) {
api_free_object(*data);
xfree(data);
}
xfree(e);
}
/// Execute autocommands for "event" and file name "fname". /// Execute autocommands for "event" and file name "fname".
/// ///
/// @param event event that occurred /// @param event event that occurred
@@ -1680,7 +1773,8 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force
// invalid. // invalid.
if (fname_io == NULL) { if (fname_io == NULL) {
if (event == EVENT_COLORSCHEME || event == EVENT_COLORSCHEMEPRE if (event == EVENT_COLORSCHEME || event == EVENT_COLORSCHEMEPRE
|| event == EVENT_OPTIONSET || event == EVENT_MODECHANGED) { || event == EVENT_OPTIONSET || event == EVENT_MODECHANGED
|| event == EVENT_MARKSET) {
autocmd_fname = NULL; autocmd_fname = NULL;
} else if (fname != NULL && !ends_excmd(*fname)) { } else if (fname != NULL && !ends_excmd(*fname)) {
autocmd_fname = fname; autocmd_fname = fname;
@@ -1742,6 +1836,7 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force
|| event == EVENT_DIRCHANGEDPRE || event == EVENT_DIRCHANGEDPRE
|| event == EVENT_FILETYPE || event == EVENT_FILETYPE
|| event == EVENT_FUNCUNDEFINED || event == EVENT_FUNCUNDEFINED
|| event == EVENT_MARKSET
|| event == EVENT_MENUPOPUP || event == EVENT_MENUPOPUP
|| event == EVENT_MODECHANGED || event == EVENT_MODECHANGED
|| event == EVENT_OPTIONSET || event == EVENT_OPTIONSET

View File

@@ -10,6 +10,7 @@
#include "nvim/buffer_defs.h" #include "nvim/buffer_defs.h"
#include "nvim/cmdexpand_defs.h" // IWYU pragma: keep #include "nvim/cmdexpand_defs.h" // IWYU pragma: keep
#include "nvim/eval/typval_defs.h" // IWYU pragma: keep #include "nvim/eval/typval_defs.h" // IWYU pragma: keep
#include "nvim/event/defs.h"
#include "nvim/ex_cmds_defs.h" // IWYU pragma: keep #include "nvim/ex_cmds_defs.h" // IWYU pragma: keep
#include "nvim/macros_defs.h" #include "nvim/macros_defs.h"
#include "nvim/pos_defs.h" #include "nvim/pos_defs.h"
@@ -68,4 +69,8 @@ enum { BUFLOCAL_PAT_LEN = 25, };
#define FOR_ALL_AUEVENTS(event) \ #define FOR_ALL_AUEVENTS(event) \
for (event_T event = (event_T)0; (int)event < (int)NUM_EVENTS; event = (event_T)((int)event + 1)) for (event_T event = (event_T)0; (int)event < (int)NUM_EVENTS; event = (event_T)((int)event + 1))
/// Stores events for execution until a known safe state.
/// This should be the default for all new autocommands.
EXTERN MultiQueue *deferred_events INIT( = NULL);
#include "autocmd.h.generated.h" #include "autocmd.h.generated.h"

View File

@@ -64,3 +64,13 @@ struct AutoPatCmd_S {
}; };
typedef kvec_t(AutoCmd) AutoCmdVec; typedef kvec_t(AutoCmd) AutoCmdVec;
typedef struct {
event_T event;
char *fname;
char *fname_io;
Buffer buf;
int group;
exarg_T *eap;
Object *data;
} AutoCmdEvent; // Used for "deferred" events, but can represent any event.

View File

@@ -156,6 +156,7 @@ void event_init(void)
loop_init(&main_loop, NULL); loop_init(&main_loop, NULL);
resize_events = multiqueue_new_child(main_loop.events); resize_events = multiqueue_new_child(main_loop.events);
autocmd_init();
signal_init(); signal_init();
// mspgack-rpc initialization // mspgack-rpc initialization
channel_init(); channel_init();

View File

@@ -7,7 +7,9 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include "nvim/api/private/helpers.h"
#include "nvim/ascii_defs.h" #include "nvim/ascii_defs.h"
#include "nvim/autocmd.h"
#include "nvim/buffer.h" #include "nvim/buffer.h"
#include "nvim/buffer_defs.h" #include "nvim/buffer_defs.h"
#include "nvim/charset.h" #include "nvim/charset.h"
@@ -87,6 +89,25 @@ void clear_fmark(fmark_T *const fm, const Timestamp timestamp)
fm->timestamp = timestamp; fm->timestamp = timestamp;
} }
/// Schedules "MarkSet" event.
///
/// @param c The name of the mark, e.g., 'a'.
/// @param pos Position of the mark in the buffer.
/// @param buf The buffer of the mark.
static void do_markset_autocmd(char c, pos_T *pos, buf_T *buf)
{
if (!has_event(EVENT_MARKSET)) {
return;
}
MAXSIZE_TEMP_DICT(data, 3);
char mark_str[2] = { c, '\0' };
PUT_C(data, "name", STRING_OBJ(((String){ .data = mark_str, .size = 1 })));
PUT_C(data, "line", INTEGER_OBJ(pos->lnum));
PUT_C(data, "col", INTEGER_OBJ(pos->col));
aucmd_defer(EVENT_MARKSET, mark_str, NULL, AUGROUP_ALL, buf, NULL, &DICT_OBJ(data));
}
// Set named mark "c" to position "pos". // Set named mark "c" to position "pos".
// When "c" is upper case use file "fnum". // When "c" is upper case use file "fnum".
// Returns OK on success, FAIL if bad name given. // Returns OK on success, FAIL if bad name given.
@@ -119,6 +140,7 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
if (c == '"') { if (c == '"') {
RESET_FMARK(&buf->b_last_cursor, *pos, buf->b_fnum, view); RESET_FMARK(&buf->b_last_cursor, *pos, buf->b_fnum, view);
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
@@ -126,10 +148,12 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
// file. // file.
if (c == '[') { if (c == '[') {
buf->b_op_start = *pos; buf->b_op_start = *pos;
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
if (c == ']') { if (c == ']') {
buf->b_op_end = *pos; buf->b_op_end = *pos;
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
@@ -143,6 +167,7 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
// Visual_mode has not yet been set, use a sane default. // Visual_mode has not yet been set, use a sane default.
buf->b_visual.vi_mode = 'v'; buf->b_visual.vi_mode = 'v';
} }
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
@@ -154,6 +179,7 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
if (ASCII_ISLOWER(c)) { if (ASCII_ISLOWER(c)) {
i = c - 'a'; i = c - 'a';
RESET_FMARK(buf->b_namedm + i, *pos, fnum, view); RESET_FMARK(buf->b_namedm + i, *pos, fnum, view);
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
if (ASCII_ISUPPER(c) || ascii_isdigit(c)) { if (ASCII_ISUPPER(c) || ascii_isdigit(c)) {
@@ -163,6 +189,7 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
i = c - 'A'; i = c - 'A';
} }
RESET_XFMARK(namedfm + i, *pos, fnum, view, NULL); RESET_XFMARK(namedfm + i, *pos, fnum, view, NULL);
do_markset_autocmd((char)c, pos, buf);
return OK; return OK;
} }
return FAIL; return FAIL;

View File

@@ -1008,6 +1008,7 @@ void free_all_mem(void)
ui_comp_free_all_mem(); ui_comp_free_all_mem();
nlua_free_all_mem(); nlua_free_all_mem();
rpc_free_all_mem(); rpc_free_all_mem();
autocmd_free_all_mem();
// should be last, in case earlier free functions deallocates arenas // should be last, in case earlier free functions deallocates arenas
arena_free_reuse_blks(); arena_free_reuse_blks();

View File

@@ -0,0 +1,286 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local api = n.api
local clear = n.clear
local command = n.command
local feed = n.feed
local poke_eventloop = n.poke_eventloop
local eval = n.eval
local eq = t.eq
local neq = t.neq
describe('MarkSet', function()
-- TODO(justinmk): support other marks?: [, ] <, > . ^ " '
before_each(function()
clear()
end)
it('emits when lowercase/uppercase/[/] marks are set', function()
command([[
let g:mark_names = ''
let g:mark_events = []
autocmd MarkSet * call add(g:mark_events, {'event': deepcopy(v:event)}) | let g:mark_names ..= expand('<amatch>')
" TODO: there is a bug lurking here.
" autocmd MarkSet * let g:mark_names ..= expand('<amatch>')
]])
api.nvim_buf_set_lines(0, 0, -1, true, {
'foo\0bar',
'baz text',
'line 3',
})
feed('ma')
feed('j')
command('mark b')
poke_eventloop()
eq('ab', eval('g:mark_names'))
-- event-data is copied to `v:event`.
eq({
{
event = {
col = 0,
line = 1,
name = 'a',
},
},
{
event = {
col = 0,
line = 2,
name = 'b',
},
},
}, eval('g:mark_events'))
feed('mA')
feed('l')
feed('mB')
feed('j')
feed('mC')
feed('x') -- TODO(justinmk): Sets [,] marks but does not emit MarkSet event (yet).
feed('0vll<esc>') -- TODO(justinmk): Sets <,> marks but does not emit MarkSet event (yet).
-- XXX: set these marks manually to exercise these cases.
api.nvim_buf_set_mark(0, '[', 2, 0, {})
api.nvim_buf_set_mark(0, ']', 2, 0, {})
api.nvim_buf_set_mark(0, '<', 2, 0, {})
api.nvim_buf_set_mark(0, '>', 2, 0, {})
api.nvim_buf_set_mark(0, '"', 2, 0, {})
poke_eventloop()
eq('abABC[]<>"', eval('g:mark_names'))
end)
it('can subscribe to specific marks by pattern', function()
command([[
let g:mark_names = ''
autocmd MarkSet [ab] let g:mark_names ..= expand('<amatch>')
]])
api.nvim_buf_set_lines(0, 0, -1, true, {
'foo\0bar',
'baz text',
})
feed('md')
feed('mc')
feed('l')
feed('mb')
feed('j')
feed('ma')
poke_eventloop()
eq('ba', eval('g:mark_names'))
end)
it('handles marks across multiple windows/buffers', function()
local orig_bufnr = api.nvim_get_current_buf()
command('enew')
local second_bufnr = api.nvim_get_current_buf()
api.nvim_buf_set_lines(second_bufnr, 0, -1, true, {
'second buffer line 1',
'second buffer line 2',
})
command('enew')
local third_bufnr = api.nvim_get_current_buf()
api.nvim_buf_set_lines(third_bufnr, 0, -1, true, {
'third buffer line 1',
'third buffer line 2',
})
command('split')
command('vsplit')
command('tabnew')
command('split')
command([[
let g:markset_events = []
autocmd MarkSet * call add(g:markset_events, { 'buf': 0 + expand('<abuf>'), 'event': deepcopy(v:event) })
]])
command('buffer ' .. orig_bufnr)
feed('gg')
feed('mA')
command('wincmd w')
command('tabnext')
feed('mB')
command('wincmd w')
command('enew')
local final_bufnr = api.nvim_get_current_buf()
api.nvim_buf_set_lines(final_bufnr, 0, -1, true, {
'final buffer after chaos',
'line 2 of final buffer',
})
feed('j')
feed('mC')
command('tabclose')
feed('mD')
poke_eventloop()
eq({
{
buf = 1,
event = {
col = 0,
line = 1,
name = 'A',
},
},
{
buf = 2,
event = {
col = 0,
line = 1,
name = 'B',
},
},
{
buf = 4,
event = {
col = 0,
line = 2,
name = 'C',
},
},
{
buf = 3,
event = {
col = 0,
line = 1,
name = 'D',
},
},
}, eval('g:markset_events'))
end)
it('handles an autocommand that calls bwipeout!', function()
api.nvim_buf_set_lines(0, 0, -1, true, {
'line 1',
'line 2',
'line 3',
})
local test_bufnr = api.nvim_get_current_buf()
command("autocmd MarkSet * let g:autocmd ..= expand('<amatch>') | bwipeout!")
command([[let g:autocmd = '']])
feed('ma')
poke_eventloop()
eq('a', eval('g:autocmd'))
eq(false, api.nvim_buf_is_valid(test_bufnr))
local current_bufnr = api.nvim_get_current_buf()
neq(current_bufnr, test_bufnr)
end)
it('when autocommand switches windows and tabs', function()
api.nvim_buf_set_lines(0, 0, -1, true, {
'first buffer line 1',
'first buffer line 2',
'first buffer line 3',
})
local first_bufnr = api.nvim_get_current_buf()
command('split')
command('enew')
api.nvim_buf_set_lines(0, 0, -1, true, {
'second buffer line 1',
'second buffer line 2',
})
local second_bufnr = api.nvim_get_current_buf()
command('tabnew')
api.nvim_buf_set_lines(0, 0, -1, true, {
'third buffer line 1',
'third buffer line 2',
'third buffer line 3',
})
local third_bufnr = api.nvim_get_current_buf()
command([[
let g:markset_events = []
autocmd MarkSet * call add(g:markset_events, {'buf': 0 + expand('<abuf>'), 'event': deepcopy(v:event)}) | wincmd w | tabnext
]])
command('buffer ' .. second_bufnr)
feed('j')
feed('mA')
command('buffer ' .. third_bufnr)
feed('l')
feed('mB')
command('buffer ' .. first_bufnr)
feed('jj')
feed('mC')
poke_eventloop()
eq({
{
buf = 2,
event = {
col = 0,
line = 2,
name = 'A',
},
},
{
buf = 3,
event = {
col = 1,
line = 1,
name = 'B',
},
},
{
buf = 1,
event = {
col = 0,
line = 3,
name = 'C',
},
},
}, eval('g:markset_events'))
eq({ 2, 0 }, api.nvim_buf_get_mark(second_bufnr, 'A'))
eq({ 1, 1 }, api.nvim_buf_get_mark(third_bufnr, 'B'))
eq({ 3, 0 }, api.nvim_buf_get_mark(first_bufnr, 'C'))
end)
end)