feat(events)!: support Optionset modified, drop BufModifiedSet #35610

Problem:
BufModifiedSet autocmd only triggered for current buffer during
redraw, causing delayed events when :wa writes non-current buffers.

Solution:
- Use the aucmd_defer approach to implement `Optionset modified`.
- Drop BufModifiedSet.
This commit is contained in:
glepnir
2026-04-27 18:30:11 +08:00
committed by GitHub
parent 695e4f8112
commit 4431713285
15 changed files with 120 additions and 68 deletions

View File

@@ -292,10 +292,6 @@ BufLeave Before leaving to another buffer. Also when
Not used for ":qa" or ":q" when exiting Vim.
*BufModifiedSet*
BufModifiedSet After the `'modified'` value of a buffer has
been changed. Special-case of |OptionSet|.
*BufNew*
BufNew After creating a new buffer (except during
startup, see |VimEnter|) or renaming an
@@ -926,9 +922,9 @@ OptionSet After setting an option (except during
always use |:noautocmd| to prevent triggering
OptionSet.
Note: Not triggered by the 'modified' option;
the |BufModifiedSet| event may be used to
handle that.
Note: The 'modified' option triggers
|OptionSet| deferred to the next event-loop
tick, even when set via |:set|.
Non-recursive: |:set| in the autocommand does
not trigger OptionSet again.

View File

@@ -24,6 +24,9 @@ API
• |nvim_exec_autocmds()|
• |nvim_clear_autocmds()|
EVENTS
• *BufModifiedSet* Use |OptionSet| with pattern "modified" instead.
------------------------------------------------------------------------------
DEPRECATED IN 0.12 *deprecated-0.12*

View File

@@ -81,6 +81,8 @@ EDITOR
EVENTS
• |BufModifiedSet| has been removed. Use the |OptionSet| event with pattern
"modified" instead.
• todo
LSP

View File

@@ -334,7 +334,8 @@ Editor:
Events (autocommands):
- Fixed inconsistent behavior in execution of nested autocommands #23368
- |BufModifiedSet|
- |OptionSet| is now fired for 'modified' when writing a file,
undoing changes, or using |:set| modified.
- |Progress|
- |RecordingEnter|
- |RecordingLeave|

View File

@@ -88,7 +88,6 @@ error('Cannot require a meta file')
--- |'BufFilePre'
--- |'BufHidden'
--- |'BufLeave'
--- |'BufModifiedSet'
--- |'BufNew'
--- |'BufNewFile'
--- |'BufRead'

View File

@@ -10,7 +10,6 @@ return {
BufFilePre = true, -- before renaming a buffer
BufHidden = true, -- just after buffer becomes hidden
BufLeave = true, -- before leaving a buffer
BufModifiedSet = true, -- after the 'modified' state of a buffer changes
BufNew = true, -- after creating any buffer
BufNewFile = true, -- when creating a buffer for a new file
BufReadCmd = true, -- read buffer using command
@@ -157,7 +156,6 @@ return {
--- @type table<string,true>
--- List of Nvim-specific events or aliases for generating syntax file.
nvim_specific = {
BufModifiedSet = true,
DiagnosticChanged = true,
LspAttach = true,
LspDetach = true,

View File

@@ -1551,6 +1551,35 @@ static void deferred_event(void **argv)
xfree(e);
}
static void deferred_optionset_modified(void **argv)
{
Error err = ERROR_INIT;
buf_T *buf = find_buffer_by_handle((handle_T)(uintptr_t)argv[0], &err);
api_clear_error(&err);
if (buf) {
bool new_val = (bool)(uintptr_t)argv[1];
OptVal old = BOOLEAN_OPTVAL(!new_val);
OptVal new = BOOLEAN_OPTVAL(new_val);
aco_save_T aco;
aucmd_prepbuf(&aco, buf);
apply_optionset_autocmd_now(kOptModified, OPT_LOCAL, old, old, old, new, NULL);
aucmd_restbuf(&aco);
}
}
/// Special case of aucmd_defer for `OptionSet modified`.
void aucmd_defer_modified(buf_T *buf, bool new_val)
{
if (!has_event(EVENT_OPTIONSET)
|| (bt_nofile(buf) && !buf->b_p_bl)
|| *get_vim_var_str(VV_OPTION_TYPE) != NUL) {
return;
}
multiqueue_put(deferred_events, deferred_optionset_modified,
(void *)(uintptr_t)buf->handle,
(void *)(uintptr_t)new_val);
}
/// Execute autocommands for "event" and file name "fname".
///
/// @param event event that occurred

View File

@@ -404,9 +404,6 @@ struct file_buffer {
int b_changed; // 'modified': Set to true if something in the
// file has been changed and not written out.
bool b_changed_invalid; // Set if BufModified autocmd has not been
// triggered since the last time b_changed was
// modified.
/// Change-identifier incremented for each change, including undo.
///

View File

@@ -150,12 +150,15 @@ void changed(buf_T *buf)
/// Also used for recovery.
void changed_internal(buf_T *buf)
{
bool was_changed = buf->b_changed;
buf->b_changed = true;
buf->b_changed_invalid = true;
ml_setflags(buf);
redraw_buf_status_later(buf);
redraw_tabline = true;
need_maketitle = true; // set window title later
if (!was_changed) {
aucmd_defer_modified(buf, true);
}
}
/// Invalidate a window's w_valid flags and w_lines[] entries after changing lines.
@@ -606,8 +609,8 @@ void changed_lines(buf_T *buf, linenr_T lnum, colnr_T col, linenr_T lnume, linen
void unchanged(buf_T *buf, bool ff, bool always_inc_changedtick)
{
if (buf->b_changed || (ff && file_ff_differs(buf, false))) {
bool was_changed = buf->b_changed;
buf->b_changed = false;
buf->b_changed_invalid = true;
ml_setflags(buf);
if (ff) {
save_file_ff(buf);
@@ -616,6 +619,9 @@ void unchanged(buf_T *buf, bool ff, bool always_inc_changedtick)
redraw_tabline = true;
need_maketitle = true; // set window title later
buf_inc_changedtick(buf);
if (was_changed) {
aucmd_defer_modified(buf, false);
}
} else if (always_inc_changedtick) {
buf_inc_changedtick(buf);
}

View File

@@ -1456,18 +1456,8 @@ void ins_redraw(bool ready)
may_trigger_win_scrolled_resized();
}
// Trigger BufModified if b_changed_invalid is set.
if (ready && has_event(EVENT_BUFMODIFIEDSET)
&& curbuf->b_changed_invalid == true
&& !pum_visible()) {
apply_autocmds(EVENT_BUFMODIFIEDSET, NULL, NULL, false, curbuf);
curbuf->b_changed_invalid = false;
}
// Trigger SafeState if nothing is pending.
may_trigger_safestate(ready
&& !ins_compl_active()
&& !pum_visible());
may_trigger_safestate(ready && !ins_compl_active() && !pum_visible());
pum_check_clear();
show_cursor_info_later(false);

View File

@@ -1326,16 +1326,6 @@ static void normal_check_text_changed(NormalState *s)
}
}
static void normal_check_buffer_modified(NormalState *s)
{
// Trigger BufModified if 'modified' changed.
if (!finish_op && has_event(EVENT_BUFMODIFIEDSET)
&& curbuf->b_changed_invalid == true) {
apply_autocmds(EVENT_BUFMODIFIEDSET, NULL, NULL, false, curbuf);
curbuf->b_changed_invalid = false;
}
}
/// If nothing is pending and we are going to wait for the user to
/// type a character, trigger SafeState.
static void normal_check_safe_state(NormalState *s)
@@ -1451,7 +1441,6 @@ static int normal_check(VimState *state)
normal_check_cursor_moved(s);
normal_check_text_changed(s);
normal_check_window_scrolled(s);
normal_check_buffer_modified(s);
normal_check_safe_state(s);
// Updating diffs from changed() does not always work properly,

View File

@@ -2060,9 +2060,9 @@ void set_option_sctx(OptIndex opt_idx, int opt_flags, sctx_T script_ctx)
}
}
/// Apply the OptionSet autocommand.
static void apply_optionset_autocmd(OptIndex opt_idx, int opt_flags, OptVal oldval, OptVal oldval_g,
OptVal oldval_l, OptVal newval, const char *errmsg)
/// Fire OptionSet autocmd directly (called from deferred context, bypasses defer logic).
void apply_optionset_autocmd_now(OptIndex opt_idx, int opt_flags, OptVal oldval, OptVal oldval_g,
OptVal oldval_l, OptVal newval, const char *errmsg)
{
// Don't do this while starting up, failure or recursively.
if (starting || errmsg != NULL || *get_vim_var_str(VV_OPTION_TYPE) != NUL) {
@@ -2101,6 +2101,20 @@ static void apply_optionset_autocmd(OptIndex opt_idx, int opt_flags, OptVal oldv
reset_v_option_vars();
}
/// For 'modified', the event is deferred.
static void apply_optionset_autocmd(OptIndex opt_idx, int opt_flags, OptVal oldval, OptVal oldval_g,
OptVal oldval_l, OptVal newval, const char *errmsg)
{
if (starting || errmsg != NULL) {
return;
}
if (opt_idx == kOptModified) {
aucmd_defer_modified(curbuf, newval.data.boolean);
return;
}
apply_optionset_autocmd_now(opt_idx, opt_flags, oldval, oldval_g, oldval_l, newval, errmsg);
}
/// Process the updated 'arabic' option value.
static const char *did_set_arabic(optset_T *args)
{

View File

@@ -20,6 +20,7 @@ local command = n.command
local exec_lua = n.exec_lua
local retry = t.retry
local source = n.source
local request = n.request
describe('autocmd', function()
before_each(clear)
@@ -856,4 +857,52 @@ describe('autocmd', function()
fn.execute('autocmd User ,,,there,is,,a,fly,,')
)
end)
describe('OptionSet modified', function()
before_each(clear)
it('is triggered when modified and un-modified', function()
source([[
let g:modified = 0
autocmd OptionSet modified let g:modified += 1
]])
request('nvim_command', [[normal! aa\<Esc>]])
eq(1, eval('g:modified'))
request('nvim_command', [[normal! u]])
eq(2, eval('g:modified'))
end)
it('triggers when writes non-current buffer #32817', function()
source([[
let g:modified = 0
let g:second_trigger_buf = 0
autocmd OptionSet modified let g:modified += 1 | if g:modified == 2 | let g:second_trigger_buf = bufnr() | endif
]])
request('nvim_command', [[edit test_a | badd test_b]])
request('nvim_command', [[normal! aa\<Esc>]])
request('nvim_command', [[let g:buf_a = bufnr()]])
request('nvim_command', [[bn]])
request('nvim_command', [[wa]])
os.remove('test_a')
eq({ 2, true }, { eval('g:modified'), eval('g:buf_a') == eval('g:second_trigger_buf') })
end)
it('OptionSet triggers correctly when modified changes', function()
command([[
autocmd OptionSet modified call add(g:messages, string(!!v:option_old) . ' -> ' . string(!!v:option_new) . ' - actual: ' . &modified)
]])
command('let g:messages = []')
local fname = t.tmpname()
command('new ' .. fname)
command("call setline(1, 'hi')")
command('write')
command('set modified')
eq({
'0 -> 1 - actual: 1',
'1 -> 0 - actual: 0',
'0 -> 1 - actual: 1',
}, eval('g:messages'))
os.remove(fname)
end)
end)
end)

View File

@@ -1,23 +0,0 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local clear = n.clear
local eq = t.eq
local eval = n.eval
local source = n.source
local request = n.request
describe('BufModified', function()
before_each(clear)
it('is triggered when modified and un-modified', function()
source([[
let g:modified = 0
autocmd BufModifiedSet * let g:modified += 1
]])
request('nvim_command', [[normal! aa\<Esc>]])
eq(1, eval('g:modified'))
request('nvim_command', [[normal! u]])
eq(2, eval('g:modified'))
end)
end)

View File

@@ -1423,12 +1423,14 @@ describe(':terminal buffer', function()
end)
it('does not allow OptionSet or b:term_title watcher to delete buffer', function()
local au = api.nvim_create_autocmd('OptionSet', { command = 'bwipeout!' })
local chan = api.nvim_open_term(0, {})
local chan = exec_lua([[
local au = vim.api.nvim_create_autocmd('OptionSet', { command = 'bwipeout!' })
local chan = vim.api.nvim_open_term(0, {})
vim.api.nvim_del_autocmd(au)
return chan
]])
matches('^E937: ', api.nvim_get_vvar('errmsg'))
api.nvim_del_autocmd(au)
api.nvim_set_vvar('errmsg', '')
api.nvim_chan_send(chan, '\027]2;SOME_TITLE\007')
eq('SOME_TITLE', api.nvim_buf_get_var(0, 'term_title'))
command([[call dictwatcheradd(b:, 'term_title', {-> execute('bwipe!')})]])