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|
LspRequest See |LspRequest|
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 Just before showing the popup menu (under 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
internal |event-loop|.)
All new editor events must be implemented using `aucmd_defer()` (and where
possible, old events should be migrated to this), so that they are processed
in a predictable manner, which avoids crashes and race conditions. See
Where possible, new editor events should be implemented using `aucmd_defer()`
(and where possible, old events migrate to this), so they are processed in
a predictable manner, which avoids crashes and race conditions. See
`do_markset_autocmd` for an example.
==============================================================================

View File

@@ -226,6 +226,8 @@ EVENTS
• New `msg_id` parameter for |ui-messages| `msg_show` event.
• '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.
• |MarkSet| is triggered after a |mark| is set by the user (currently doesn't
support implicit marks like |'[| or |'<|, …).
HIGHLIGHTS

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,9 @@
#include <stdlib.h>
#include <string.h>
#include "nvim/api/private/converter.h"
#include "nvim/autocmd.h"
#include "nvim/autocmd_defs.h"
#include "nvim/buffer.h"
#include "nvim/charset.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(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)
{
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".
///
/// @param event event that occurred
@@ -1680,7 +1773,8 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force
// invalid.
if (fname_io == NULL) {
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;
} else if (fname != NULL && !ends_excmd(*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_FILETYPE
|| event == EVENT_FUNCUNDEFINED
|| event == EVENT_MARKSET
|| event == EVENT_MENUPOPUP
|| event == EVENT_MODECHANGED
|| event == EVENT_OPTIONSET

View File

@@ -10,6 +10,7 @@
#include "nvim/buffer_defs.h"
#include "nvim/cmdexpand_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/macros_defs.h"
#include "nvim/pos_defs.h"
@@ -68,4 +69,8 @@ enum { BUFLOCAL_PAT_LEN = 25, };
#define FOR_ALL_AUEVENTS(event) \
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"

View File

@@ -64,3 +64,13 @@ struct AutoPatCmd_S {
};
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);
resize_events = multiqueue_new_child(main_loop.events);
autocmd_init();
signal_init();
// mspgack-rpc initialization
channel_init();

View File

@@ -7,7 +7,9 @@
#include <stdio.h>
#include <string.h>
#include "nvim/api/private/helpers.h"
#include "nvim/ascii_defs.h"
#include "nvim/autocmd.h"
#include "nvim/buffer.h"
#include "nvim/buffer_defs.h"
#include "nvim/charset.h"
@@ -87,6 +89,25 @@ void clear_fmark(fmark_T *const fm, const 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".
// When "c" is upper case use file "fnum".
// 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 == '"') {
RESET_FMARK(&buf->b_last_cursor, *pos, buf->b_fnum, view);
do_markset_autocmd((char)c, pos, buf);
return OK;
}
@@ -126,10 +148,12 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
// file.
if (c == '[') {
buf->b_op_start = *pos;
do_markset_autocmd((char)c, pos, buf);
return OK;
}
if (c == ']') {
buf->b_op_end = *pos;
do_markset_autocmd((char)c, pos, buf);
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.
buf->b_visual.vi_mode = 'v';
}
do_markset_autocmd((char)c, pos, buf);
return OK;
}
@@ -154,6 +179,7 @@ int setmark_pos(int c, pos_T *pos, int fnum, fmarkv_T *view_pt)
if (ASCII_ISLOWER(c)) {
i = c - 'a';
RESET_FMARK(buf->b_namedm + i, *pos, fnum, view);
do_markset_autocmd((char)c, pos, buf);
return OK;
}
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';
}
RESET_XFMARK(namedfm + i, *pos, fnum, view, NULL);
do_markset_autocmd((char)c, pos, buf);
return OK;
}
return FAIL;

View File

@@ -1008,6 +1008,7 @@ void free_all_mem(void)
ui_comp_free_all_mem();
nlua_free_all_mem();
rpc_free_all_mem();
autocmd_free_all_mem();
// should be last, in case earlier free functions deallocates arenas
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)