diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 36b09f211c..697f6303b4 100644 --- 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 index 3391736b5f..0a4b1a7d74 100644 --- 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 index ba2698e9f1..b32b7f98a7 100644 --- 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 index 2e291b3e26..2c61024e84 100644 --- 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 index 590d79972f..2cd6514f78 100644 --- 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 index 38f754c273..0d0471c671 100644 --- 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 index 687f07b744..4b0167f86b 100644 --- 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 index 9ab66f5729..46c143062a 100644 --- a/src/nvim/autocmd.c +++ b/src/nvim/autocmd.c @@ -7,7 +7,9 @@ #include #include +#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 `` (the "pattern"). NULL/empty means use actual filename. +/// @param fname_io Filename to use for on cmdline, NULL means use `fname`. +/// @param group Group ID or AUGROUP_ALL +/// @param buf Buffer for +/// @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 index 8df9dd9633..6789dad66f 100644 --- 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 index 0b6a75eb36..115801cffe 100644 --- 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 index f0f00387a4..d28b6cd8fc 100644 --- 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 index 06edf4730d..fabcfc37f9 100644 --- a/src/nvim/mark.c +++ b/src/nvim/mark.c @@ -7,7 +7,9 @@ #include #include +#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 index 0623834fe4..47c4e42dbb 100644 --- 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 new file mode 100644 index 0000000000..d12bde304b --- /dev/null +++ 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('') + " TODO: there is a bug lurking here. + " autocmd MarkSet * let g:mark_names ..= expand('') + ]]) + + 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') -- 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('') + ]]) + + 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(''), '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('') | 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(''), '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)