commit 551bb63d449f85f89dee8ee7f842274d4566fcc9
parent 64cf63a88110459d70d140f6074fa014adaef331
Author: Nathan Smith <nathan@nathansmith.io>
Date: Sun, 7 Dec 2025 12:13:31 -0800
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>
Diffstat:
14 files changed, 448 insertions(+), 4 deletions(-)
diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt
@@ -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
diff --git a/runtime/doc/dev_arch.txt b/runtime/doc/dev_arch.txt
@@ -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.
==============================================================================
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -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
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
@@ -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|,
diff --git a/runtime/lua/vim/_meta/api_keysets.lua b/runtime/lua/vim/_meta/api_keysets.lua
@@ -160,6 +160,7 @@ error('Cannot require a meta file')
--- |'LspProgress'
--- |'LspRequest'
--- |'LspTokenUpdate'
+--- |'MarkSet'
--- |'MenuPopup'
--- |'ModeChanged'
--- |'OptionSet'
diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua
@@ -2213,6 +2213,7 @@ vim.go.ei = vim.go.eventignore
--- `LspProgress`,
--- `LspRequest`,
--- `LspTokenUpdate`,
+--- `MarkSet`,
--- `MenuPopup`,
--- `ModeChanged`,
--- `OptionSet`,
diff --git a/src/nvim/auevents.lua b/src/nvim/auevents.lua
@@ -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
diff --git a/src/nvim/autocmd.c b/src/nvim/autocmd.c
@@ -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
diff --git a/src/nvim/autocmd.h b/src/nvim/autocmd.h
@@ -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"
diff --git a/src/nvim/autocmd_defs.h b/src/nvim/autocmd_defs.h
@@ -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.
diff --git a/src/nvim/main.c b/src/nvim/main.c
@@ -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();
diff --git a/src/nvim/mark.c b/src/nvim/mark.c
@@ -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;
diff --git a/src/nvim/memory.c b/src/nvim/memory.c
@@ -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();
diff --git a/test/functional/autocmd/markset_spec.lua b/test/functional/autocmd/markset_spec.lua
@@ -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)