patch 9.1.1782: buffer-listener callbacks may not match buffer content

Problem:  buffer-listener callbacks may not match buffer content, since
          they are buffered until the screen is updated.
Solution: Allow to handle buffer-callbacks un-buffered, meaning to
          handle those changes as soon as they happen (Paul Ollis).

fixes: #18183
closes: #18295

Signed-off-by: Paul Ollis <paul@cleversheep.org>
Signed-off-by: Christian Brabandt <cb@256bit.org>
This commit is contained in:
Paul Ollis
2025-09-21 18:53:40 +00:00
committed by Christian Brabandt
parent 3a6cf6d53b
commit e87d17ecfb
10 changed files with 490 additions and 113 deletions

View File

@ -149,8 +149,89 @@ changed_internal(void)
}
#ifdef FEAT_EVAL
// Set when listener callbacks are being invoked.
static int recursive = FALSE;
static long next_listener_id = 0;
// A flag that is set when any buffer listener housekeeping is required.
// Currently the only condition is when a listener is marked for removal.
static bool houskeeping_required;
/*
* Remove a given listener_T entry from its containing list.
*/
static void
remove_listener_from_list(
listener_T **list,
listener_T *lnr,
listener_T *prev)
{
if (prev != NULL)
prev->lr_next = lnr->lr_next;
else
*list = lnr->lr_next;
free_callback(&lnr->lr_callback);
vim_free(lnr);
}
/*
* Clean up a buffer change listener list.
*
* If "all" is TRUE then all entries are removed. Otherwise only those with an ID
* of zero are removed. If "buf" is non-NULL then the buffer's recorded changes
* will be discarded in the event that all listeners were removed.
*
*/
static void
clean_listener_list(buf_T *buf, listener_T **list, bool all)
{
listener_T *prev;
listener_T *lnr;
listener_T *next;
prev = NULL;
for (lnr = *list; lnr != NULL; lnr = next)
{
next = lnr->lr_next;
if (all || lnr->lr_id == 0)
remove_listener_from_list(list, lnr, prev);
else
prev = lnr;
}
// Drop any recorded changes for a buffer with no listeners.
if (buf != NULL)
{
if (*list == NULL && buf->b_recorded_changes != NULL)
{
list_unref(buf->b_recorded_changes);
buf->b_recorded_changes = NULL;
}
}
}
/*
* Perform houskeeping tasks for buffer change listeners.
*
* This does nothing unless the "houskeeping_required" flag has been set.
*/
static void
perform_listener_housekeeping(void)
{
buf_T *buf;
if (houskeeping_required)
{
FOR_ALL_BUFFERS(buf)
{
clean_listener_list(buf, &buf->b_listener, FALSE);
clean_listener_list(NULL, &buf->b_sync_listener, FALSE);
}
houskeeping_required = FALSE;
}
}
/*
* Check if the change at "lnum" is above or overlaps with an existing
* change. If above then flush changes and invoke listeners.
@ -162,12 +243,13 @@ check_recorded_changes(
linenr_T lnume,
long xtra)
{
perform_listener_housekeeping();
if (buf->b_recorded_changes == NULL || xtra == 0)
return;
listitem_T *li;
linenr_T prev_lnum;
linenr_T prev_lnume;
linenr_T prev_lnum;
linenr_T prev_lnume;
FOR_ALL_LIST_ITEMS(buf->b_recorded_changes, li)
{
@ -188,6 +270,9 @@ check_recorded_changes(
/*
* Record a change for listeners added with listener_add().
* Always for the current buffer.
*
* This only deals with listeners that are prepared to accept multiple buffered
* changes.
*/
static void
may_record_change(
@ -198,6 +283,7 @@ may_record_change(
{
dict_T *dict;
perform_listener_housekeeping();
if (curbuf->b_listener == NULL)
return;
@ -234,8 +320,17 @@ f_listener_add(typval_T *argvars, typval_T *rettv)
callback_T callback;
listener_T *lnr;
buf_T *buf = curbuf;
int unbuffered = 0;
if (in_vim9script() && check_for_opt_buffer_arg(argvars, 1) == FAIL)
if (recursive)
{
emsg(_(e_cannot_add_listener_in_listener_callback));
return;
}
if (in_vim9script() && (
check_for_opt_buffer_arg(argvars, 1) == FAIL
|| check_for_opt_bool_arg(argvars, 2) == FAIL))
return;
callback = get_callback(&argvars[0]);
@ -250,6 +345,8 @@ f_listener_add(typval_T *argvars, typval_T *rettv)
free_callback(&callback);
return;
}
if (argvars[2].v_type != VAR_UNKNOWN)
unbuffered = (int)tv_get_bool(&argvars[2]);
}
lnr = ALLOC_CLEAR_ONE(listener_T);
@ -258,8 +355,23 @@ f_listener_add(typval_T *argvars, typval_T *rettv)
free_callback(&callback);
return;
}
lnr->lr_next = buf->b_listener;
buf->b_listener = lnr;
// Perform any pending housekeeping and then make sure any buffered change
// reports are flushed so that the new listener does not see out of date
// changes.
perform_listener_housekeeping();
invoke_listeners(buf);
if (unbuffered)
{
lnr->lr_next = buf->b_sync_listener;
buf->b_sync_listener = lnr;
}
else
{
lnr->lr_next = buf->b_listener;
buf->b_listener = lnr;
}
set_callback(&lnr->lr_callback, &callback);
if (callback.cb_free_name)
@ -277,6 +389,9 @@ f_listener_flush(typval_T *argvars, typval_T *rettv UNUSED)
{
buf_T *buf = curbuf;
if (recursive)
return;
if (in_vim9script() && check_for_opt_buffer_arg(argvars, 0) == FAIL)
return;
@ -286,29 +401,43 @@ f_listener_flush(typval_T *argvars, typval_T *rettv UNUSED)
if (buf == NULL)
return;
}
perform_listener_housekeeping();
invoke_listeners(buf);
}
static void
remove_listener(buf_T *buf, listener_T *lnr, listener_T *prev)
/*
* Find the buffer change listener entry for a given unique ID.
*/
static listener_T *
find_listener(
int id,
listener_T *list_start,
listener_T **prev)
{
if (prev != NULL)
prev->lr_next = lnr->lr_next;
else
buf->b_listener = lnr->lr_next;
free_callback(&lnr->lr_callback);
vim_free(lnr);
listener_T *next;
listener_T *lnr;
*prev = NULL;
for (lnr = list_start; lnr != NULL; lnr = next)
{
next = lnr->lr_next;
if (lnr->lr_id == id)
return lnr;
*prev = lnr;
}
return NULL;
}
/*
* listener_remove() function
*
* This simply marks the listener_T entry as unused, by setting its ID to zero.
* The listener_T entry gets removed later by housekeeping.
*/
void
f_listener_remove(typval_T *argvars, typval_T *rettv)
{
listener_T *lnr;
listener_T *next;
listener_T *prev;
int id;
buf_T *buf;
@ -319,29 +448,23 @@ f_listener_remove(typval_T *argvars, typval_T *rettv)
id = tv_get_number(argvars);
FOR_ALL_BUFFERS(buf)
{
prev = NULL;
for (lnr = buf->b_listener; lnr != NULL; lnr = next)
lnr = find_listener(id, buf->b_listener, &prev);
if (lnr == NULL)
lnr = find_listener(id, buf->b_sync_listener, &prev);
if (lnr != NULL)
{
next = lnr->lr_next;
if (lnr->lr_id == id)
{
if (textlock > 0)
{
// in invoke_listeners(), clear ID and delete later
lnr->lr_id = 0;
return;
}
remove_listener(buf, lnr, prev);
rettv->vval.v_number = 1;
return;
}
prev = lnr;
// Clear the ID to indicate that the listener is unused flag
// houskeeping.
lnr->lr_id = 0;
houskeeping_required = TRUE;
rettv->vval.v_number = 1;
return;
}
}
}
/*
* Called before inserting a line above "lnum"/"lnum3" or deleting line "lnum"
* Called before inserting a line above "lnum"/"lnume" or deleting line "lnum"
* to "lnume".
*/
void
@ -350,6 +473,98 @@ may_invoke_listeners(buf_T *buf, linenr_T lnum, linenr_T lnume, int added)
check_recorded_changes(buf, lnum, lnume, added);
}
/*
* Common processing for invoke_listeners and invoke_sync_listeners.
*/
static void
invoke_listener_set(
buf_T *buf,
linenr_T start,
linenr_T end,
long added,
list_T *recorded_changes,
listener_T *listeners)
{
int save_updating_screen = updating_screen;
listener_T *lnr;
typval_T rettv;
typval_T argv[6];
argv[0].v_type = VAR_NUMBER;
argv[0].vval.v_number = buf->b_fnum; // a:bufnr
argv[1].v_type = VAR_NUMBER;
argv[1].vval.v_number = start;
argv[2].v_type = VAR_NUMBER;
argv[2].vval.v_number = end;
argv[3].v_type = VAR_NUMBER;
argv[3].vval.v_number = added;
argv[4].v_type = VAR_LIST;
argv[4].vval.v_list = recorded_changes;
// Protect against recursive callbacks, lock the buffer against changes and
// set the updating_screen flag to prevent channel input processing, which
// might also try to update the buffer.
recursive = TRUE;
++textlock;
updating_screen = TRUE;
for (lnr = listeners; lnr != NULL; lnr = lnr->lr_next)
{
call_callback(&lnr->lr_callback, -1, &rettv, 5, argv);
clear_tv(&rettv);
}
--textlock;
if (save_updating_screen)
updating_screen = TRUE;
else
after_updating_screen(TRUE);
recursive = FALSE;
}
/*
* Called when any change occurs: invoke listeners added with the "unbuffered"
* parameter set.
*/
static void
invoke_sync_listeners(
buf_T *buf,
linenr_T start,
colnr_T col,
linenr_T end,
long added)
{
list_T *recorded_changes;
dict_T *dict;
if (recursive || curbuf->b_sync_listener == NULL)
return;
// Create a single entry list to store the details of the change (including
// the column).
recorded_changes = list_alloc();
if (recorded_changes == NULL) // out of memory
return;
++recorded_changes->lv_refcount;
recorded_changes->lv_lock = VAR_FIXED;
dict = dict_alloc();
if (dict == NULL)
return;
dict_add_number(dict, "lnum", (varnumber_T)start);
dict_add_number(dict, "end", (varnumber_T)end);
dict_add_number(dict, "added", (varnumber_T)added);
dict_add_number(dict, "col", (varnumber_T)col + 1);
list_append_dict(recorded_changes, dict);
invoke_listener_set(
buf, start, end, added, recorded_changes, buf->b_sync_listener);
list_unref(recorded_changes);
}
/*
* Called when a sequence of changes is done: invoke listeners added with
* listener_add().
@ -357,30 +572,15 @@ may_invoke_listeners(buf_T *buf, linenr_T lnum, linenr_T lnume, int added)
void
invoke_listeners(buf_T *buf)
{
listener_T *lnr;
typval_T rettv;
typval_T argv[6];
listitem_T *li;
linenr_T start = MAXLNUM;
linenr_T end = 0;
linenr_T added = 0;
int save_updating_screen = updating_screen;
static int recursive = FALSE;
listener_T *next;
listener_T *prev;
if (buf->b_recorded_changes == NULL // nothing changed
|| buf->b_listener == NULL // no listeners
|| buf->b_listener == NULL // no listeners
|| recursive) // already busy
return;
recursive = TRUE;
// Block messages on channels from being handled, so that they don't make
// text changes here.
++updating_screen;
argv[0].v_type = VAR_NUMBER;
argv[0].vval.v_number = buf->b_fnum; // a:bufnr
FOR_ALL_LIST_ITEMS(buf->b_recorded_changes, li)
{
@ -394,43 +594,12 @@ invoke_listeners(buf_T *buf)
end = lnum;
added += dict_get_number(li->li_tv.vval.v_dict, "added");
}
argv[1].v_type = VAR_NUMBER;
argv[1].vval.v_number = start;
argv[2].v_type = VAR_NUMBER;
argv[2].vval.v_number = end;
argv[3].v_type = VAR_NUMBER;
argv[3].vval.v_number = added;
argv[4].v_type = VAR_LIST;
argv[4].vval.v_list = buf->b_recorded_changes;
++textlock;
invoke_listener_set(
buf, start, end, added, buf->b_recorded_changes, buf->b_listener);
for (lnr = buf->b_listener; lnr != NULL; lnr = lnr->lr_next)
{
call_callback(&lnr->lr_callback, -1, &rettv, 5, argv);
clear_tv(&rettv);
}
// If f_listener_remove() was called may have to remove a listener now.
prev = NULL;
for (lnr = buf->b_listener; lnr != NULL; lnr = next)
{
next = lnr->lr_next;
if (lnr->lr_id == 0)
remove_listener(buf, lnr, prev);
else
prev = lnr;
}
--textlock;
list_unref(buf->b_recorded_changes);
buf->b_recorded_changes = NULL;
if (save_updating_screen)
updating_screen = TRUE;
else
after_updating_screen(TRUE);
recursive = FALSE;
}
/*
@ -439,17 +608,10 @@ invoke_listeners(buf_T *buf)
void
remove_listeners(buf_T *buf)
{
listener_T *lnr;
listener_T *next;
for (lnr = buf->b_listener; lnr != NULL; lnr = next)
{
next = lnr->lr_next;
free_callback(&lnr->lr_callback);
vim_free(lnr);
}
buf->b_listener = NULL;
clean_listener_list(buf, &buf->b_listener, TRUE);
clean_listener_list(NULL, &buf->b_sync_listener, TRUE);
}
#endif
/*
@ -475,6 +637,12 @@ changed_common(
changed();
#ifdef FEAT_EVAL
// Immediately send this change to any listeners that require changes no to
// be buffered.
invoke_sync_listeners(curbuf, lnum, col, lnume, xtra);
// If there are any listeners accepting buffered changes then add changes
// to the current buffer's list, flushing previous changes first if necessary.
may_record_change(lnum, col, lnume, xtra);
#endif
#ifdef FEAT_DIFF
@ -500,7 +668,7 @@ changed_common(
else
{
// Don't create a new entry when the line number is the same
// as the last one and the column is not too far away. Avoids
// as the last one and the column is not too far away. Avoids
// creating many entries for typing "xxxxx".
p = &curbuf->b_changelist[curbuf->b_changelistlen - 1];
if (p->lnum != lnum)
@ -626,7 +794,7 @@ changed_common(
// Check if any w_lines[] entries have become invalid.
// For entries below the change: Correct the lnums for
// inserted/deleted lines. Makes it possible to stop displaying
// inserted/deleted lines. Makes it possible to stop displaying
// after the change.
for (i = 0; i < wp->w_lines_valid; ++i)
if (wp->w_lines[i].wl_valid)
@ -1073,7 +1241,7 @@ ins_char_bytes(char_u *buf, int charlen)
{
if (State & VREPLACE_FLAG)
{
colnr_T new_vcol = 0; // init for GCC
colnr_T new_vcol = 0; // init for GCC
colnr_T vcol;
int old_list;
@ -1342,7 +1510,7 @@ del_bytes(
// If the old line has been allocated the deletion can be done in the
// existing line. Otherwise a new line has to be allocated
// Can't do this when using Netbeans, because we would need to invoke
// netbeans_removed(), which deallocates the line. Let ml_replace() take
// netbeans_removed(), which deallocates the line. Let ml_replace() take
// care of notifying Netbeans.
#ifdef FEAT_NETBEANS_INTG
if (netbeans_active())
@ -1470,7 +1638,7 @@ open_line(
// In MODE_VREPLACE state, a NL replaces the rest of the line, and
// starts replacing the next line, so push all of the characters left
// on the line onto the replace stack. We'll push any other characters
// on the line onto the replace stack. We'll push any other characters
// that might be replaced at the start of the next line (due to
// autoindent etc) a bit later.
replace_push(NUL); // Call twice because BS over NL expects it

View File

@ -3795,3 +3795,7 @@ EXTERN char e_socket_server_unavailable[]
#endif
EXTERN char e_osc_response_timed_out[]
INIT(= N_("E1568: OSC command response timed out: %.*s"));
#ifdef FEAT_EVAL
EXTERN char e_cannot_add_listener_in_listener_callback[]
INIT(= N_("E1569: Cannot use listener_add in a listener callback"));
#endif

View File

@ -1258,7 +1258,6 @@ static argcheck_T arg1_string_or_list_any[] = {arg_string_or_list_any};
static argcheck_T arg1_string_or_list_string[] = {arg_string_or_list_string};
static argcheck_T arg1_string_or_nr[] = {arg_string_or_nr};
static argcheck_T arg1_string_or_blob[] = {arg_string_or_blob};
static argcheck_T arg2_any_buffer[] = {arg_any, arg_buffer};
static argcheck_T arg2_buffer_any[] = {arg_buffer, arg_any};
static argcheck_T arg2_buffer_bool[] = {arg_buffer, arg_bool};
static argcheck_T arg2_buffer_list_any[] = {arg_buffer, arg_list_any};
@ -1301,6 +1300,7 @@ static argcheck_T arg2_string_or_list_number[] = {arg_string_or_list_any, arg_nu
static argcheck_T arg2_string_string_or_number[] = {arg_string, arg_string_or_nr};
static argcheck_T arg2_blob_dict[] = {arg_blob, arg_dict_any};
static argcheck_T arg2_list_or_tuple_string[] = {arg_list_or_tuple, arg_string};
static argcheck_T arg3_any_buffer_bool[] = {arg_any, arg_buffer, arg_bool};
static argcheck_T arg3_any_list_dict[] = {arg_any, arg_list_any, arg_dict_any};
static argcheck_T arg3_buffer_lnum_lnum[] = {arg_buffer, arg_lnum, arg_lnum};
static argcheck_T arg3_buffer_number_number[] = {arg_buffer, arg_number, arg_number};
@ -2507,7 +2507,7 @@ static const funcentry_T global_functions[] =
ret_string, f_list2str},
{"list2tuple", 1, 1, FEARG_1, arg1_list_any,
ret_tuple_any, f_list2tuple},
{"listener_add", 1, 2, FEARG_2, arg2_any_buffer,
{"listener_add", 1, 3, FEARG_2, arg3_any_buffer_bool,
ret_number, f_listener_add},
{"listener_flush", 0, 1, FEARG_1, arg1_buffer,
ret_void, f_listener_flush},

5
src/po/vim.pot generated
View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Vim\n"
"Report-Msgid-Bugs-To: vim-dev@vim.org\n"
"POT-Creation-Date: 2025-08-27 19:10+0200\n"
"POT-Creation-Date: 2025-09-21 18:48+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -8836,6 +8836,9 @@ msgstr ""
msgid "E1568: OSC command response timed out: %.*s"
msgstr ""
msgid "E1569: Cannot use listener_add in a listener callback"
msgstr ""
#. type of cmdline window or 0
#. result of cmdline window or 0
#. buffer of cmdline window or NULL

View File

@ -3497,7 +3497,8 @@ struct file_buffer
dictitem_T b_bufvar; // variable for "b:" Dictionary
dict_T *b_vars; // internal variables, local to buffer
listener_T *b_listener;
listener_T *b_listener; // Listeners accepting buffered reports.
listener_T *b_sync_listener; // Listeners requiring unbuffered reports.
list_T *b_recorded_changes;
#endif
#ifdef FEAT_PROP_POPUP

View File

@ -8,6 +8,14 @@ func s:StoreList(s, e, a, l)
let s:list = a:l
endfunc
func s:StoreListUnbuffered(s, e, a, l)
let s:start = a:s
let s:end = a:e
let s:added = a:a
let s:text = getline(a:s)
let s:list2 = a:l
endfunc
func s:AnotherStoreList(l)
let s:list2 = a:l
endfunc
@ -131,9 +139,15 @@ func Test_listening()
call setline(1, 'asdfasdf')
redraw
call assert_equal([], s:list)
bwipe!
endfunc
" Trying to change the list fails
func Test_change_list_is_locked()
" Trying to change the list passed to the callback fails
new
call setline(1, ['one', 'two'])
let id = listener_add({b, s, e, a, l -> s:EvilStoreList(l)})
let s:list3 = []
call setline(1, 'asdfasdf')
redraw
@ -143,6 +157,166 @@ func Test_listening()
bwipe!
endfunc
func Test_change_list_is_locked_unbuffered()
" Trying to change the list passed to the callback fails (unbuffered mode).
new
call setline(1, ['one', 'two'])
let id = listener_add({b, s, e, a, l -> s:EvilStoreList(l)}, bufnr(), v:true)
let s:list3 = []
call setline(1, 'asdfasdf')
redraw
call assert_equal([{'lnum': 1, 'end': 2, 'col': 1, 'added': 0}], s:list3)
eval id->listener_remove()
bwipe!
endfunc
func Test_new_listener_does_not_receive_ood_changes()
new
call setline(1, ['one', 'two', 'three'])
let s:list = []
let s:list2 = []
" Add a listener and make a change.
let id = listener_add({b, s, e, a, l -> s:StoreList(s, e, a, l)})
call setline(1, 'one one')
" Add a second listener, it should not see the above change to the buffer,
" only the change after it was added.
let id = listener_add({b, s, e, a, l -> s:AnotherStoreList(l)})
call setline(2, 'two two')
redraw
call assert_equal([{'lnum': 2, 'end': 3, 'col': 1, 'added': 0}], s:list)
call listener_remove(id)
bwipe!
endfunc
func Test_callbacks_do_not_recurse()
func DodgyExtendList(b, s, e, a, l)
call extend(s:list, a:l)
if len(s:list) < 3 " Limit recursion in the face of test failure.
call listener_flush()
redraw
endif
endfunc
new
call setline(1, ['one', 'two', 'three'])
let s:list = []
" Add a listener and make a change.
let id = listener_add("DodgyExtendList")
call setline(1, 'one one')
" The callback should only be invoked once, i.e. recursion is blocked.
redraw
call assert_equal([{'lnum': 1, 'end': 2, 'col': 1, 'added': 0}], s:list)
call listener_remove(id)
bwipe!
endfunc
func Test_clean_up_after_last_listener_removed()
new
call setline(1, ['one', 'two', 'three'])
let s:list = []
" Add a listener, make a change, but then remove the listener before the
" listener gets invoked.
let id = listener_add({b, s, e, a, l -> s:StoreList(s, e, a, l)})
call setline(3, 'three three')
let ok = listener_remove(id)
call assert_equal(1, ok)
" Further buffer changes should (obviously) have no effect.
let s:list = []
call setline(2, 'two two')
redraw
call assert_equal([], s:list)
" Add a new listener, it should not see the above change to line 3 of the
" buffer.
let id = listener_add({b, s, e, a, l -> s:StoreList(s, e, a, l)})
redraw
call assert_equal([], s:list)
call listener_remove(id)
bwipe!
endfunc
func Test_a_callback_may_not_add_a_listener()
func ListenerWotAdds_listener(bufnr, start, end, added, changes)
call s:StoreList(a:start, a:end, a:added, a:changes)
call assert_fails(
\ "call listener_add({b, s, e, a, l -> s:AnotherStoreList(l)})", "E1569:")
endfunc
new
call setline(1, ['one', 'two', 'three'])
let s:list = []
" Add a listener, make a change, but then remove the listener before the
" listener gets invoked.
let id = listener_add("ListenerWotAdds_listener")
call setline(3, 'three three')
redraw
call assert_equal([{'lnum': 3, 'end': 4, 'col': 1, 'added': 0}], s:list)
let s:list2 = []
call setline(2, 'two two')
redraw
call assert_equal([{'lnum': 2, 'end': 3, 'col': 1, 'added': 0}], s:list)
call assert_equal([], s:list2)
call listener_remove(id)
bwipe!
endfunc
func Test_changes_can_be_unbuffered()
new
call setline(1, ['one', 'two', 'three'])
let s:list = []
let s:list2 = []
" Add both a buffered and an unbuffered listener.
let id_a = listener_add({b, s, e, a, l -> s:StoreList(s, e, a, l)})
let id_b = listener_add(
\ {b, s, e, a, l -> s:StoreListUnbuffered(s, e, a, l)},
\ bufnr(), v:true)
" Make a change, which only the second listener should see immediately.
call setline(2, 'two two')
call assert_equal([{'lnum': 2, 'end': 3, 'col': 1, 'added': 0}], s:list2)
call assert_equal(2, s:start)
call assert_equal(3, s:end)
call assert_equal(0, s:added)
call assert_equal([], s:list)
" Make another change, which only the second listener should see immediately.
call setline(3, 'three three')
call assert_equal([{'lnum': 3, 'end': 4, 'col': 1, 'added': 0}], s:list2)
call assert_equal(3, s:start)
call assert_equal(4, s:end)
call assert_equal(0, s:added)
call assert_equal([], s:list)
" Force changes to be flushed. Only the first listener should be invoked,
" with both the above changes.
let s:list2 = []
redraw
call assert_equal([
\ {'lnum': 2, 'end': 3, 'col': 1, 'added': 0},
\ {'lnum': 3, 'end': 4, 'col': 1, 'added': 0}], s:list)
call assert_equal([], s:list2)
call listener_remove(id_a)
call listener_remove(id_b)
bwipe!
endfunc
func s:StoreListArgs(buf, start, end, added, list)
let s:buf = a:buf
let s:start = a:start

View File

@ -724,6 +724,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
/**/
1782,
/**/
1781,
/**/