commit 7da4d6abe2491b165e8cb94d4d774d8be7b23da1
parent 7be031f397da2555e950a4acf753b1219ef7dfe1
Author: Riccardo Mazzarini <me@noib3.dev>
Date: Fri, 21 Nov 2025 06:40:08 +0100
fix(api): on_bytes gets stale data on :substitute #36487
Problem: `extmark_splice()` was being called before `ml_replace()`,
which caused the on_bytes callback to be invoked with the old buffer
text instead of the new text.
Solution: store metadata for each match in a growing array, call
`ml_replace()` once to update the buffer, then call `extmark_splice()`
once per match.
Closes https://github.com/neovim/neovim/issues/36370.
Diffstat:
2 files changed, 300 insertions(+), 4 deletions(-)
diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c
@@ -3771,6 +3771,25 @@ static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_n
bool skip_match = false;
linenr_T sub_firstlnum; // nr of first sub line
+ // Track where substitutions started (set once per line).
+ linenr_T lnum_start = 0;
+
+ // Track per-line data for each match.
+ // Will be sent as a batch to `extmark_splice` after the substitution is done.
+ typedef struct {
+ int start_col; // Position in new text where replacement goes
+ lpos_T start; // Match start position in original text
+ lpos_T end; // Match end position in original text
+ int matchcols; // Columns deleted from original text
+ bcount_t matchbytes; // Bytes deleted from original text
+ int subcols; // Columns in replacement text
+ bcount_t subbytes; // Bytes in replacement text
+ linenr_T lnum_before; // Line number before this substitution
+ linenr_T lnum_after; // Line number after this substitution
+ } LineData;
+
+ kvec_t(LineData) line_matches = KV_INITIAL_VALUE;
+
// The new text is build up step by step, to avoid too much
// copying. There are these pieces:
// sub_firstline The old text, unmodified.
@@ -4122,7 +4141,7 @@ static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_n
// 3. Substitute the string. During 'inccommand' preview only do this if
// there is a replace pattern.
if (cmdpreview_ns <= 0 || has_second_delim) {
- linenr_T lnum_start = lnum; // save the start lnum
+ lnum_start = lnum; // save the start lnum
int save_ma = curbuf->b_p_ma;
int save_sandbox = sandbox;
if (subflags.do_count) {
@@ -4212,6 +4231,9 @@ static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_n
}
replaced_bytes += end.col - start.col;
+ // Save the line number before processing newlines.
+ linenr_T lnum_before_newlines = lnum;
+
// Now the trick is to replace CTRL-M chars with a real line
// break. This would make it impossible to insert a CTRL-M in
// the text. The line break can be avoided by preceding the
@@ -4263,9 +4285,18 @@ static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_n
u_save_cursor();
did_save = true;
}
- extmark_splice(curbuf, (int)lnum_start - 1, start_col,
- end.lnum - start.lnum, matchcols, replaced_bytes,
- lnum - lnum_start, subcols, sublen - 1, kExtmarkUndo);
+
+ // Store extmark data for this match.
+ LineData *data = kv_pushp(line_matches);
+ data->start_col = start_col;
+ data->start = start;
+ data->end = end;
+ data->matchcols = matchcols;
+ data->matchbytes = replaced_bytes;
+ data->subcols = subcols;
+ data->subbytes = sublen - 1;
+ data->lnum_before = lnum_before_newlines;
+ data->lnum_after = lnum;
}
// 4. If subflags.do_all is set, find next match.
@@ -4316,6 +4347,21 @@ skip:
}
ml_replace(lnum, new_start, true);
+ // Call extmark_splice for each match on this line.
+ for (size_t match_idx = 0; match_idx < kv_size(line_matches); match_idx++) {
+ LineData *match = &kv_A(line_matches, match_idx);
+
+ extmark_splice(curbuf, (int)match->lnum_before - 1, match->start_col,
+ match->end.lnum - match->start.lnum, match->matchcols,
+ match->matchbytes,
+ match->lnum_after - match->lnum_before,
+ match->subcols,
+ match->subbytes, kExtmarkUndo);
+ }
+
+ // Reset the match data for the next line.
+ kv_size(line_matches) = 0;
+
if (nmatch_tl > 0) {
// Matched lines have now been substituted and are
// useless, delete them. The part after the match
@@ -4411,6 +4457,7 @@ skip:
}
xfree(new_start); // for when substitute was cancelled
XFREE_CLEAR(sub_firstline); // free the copy of the original line
+ kv_destroy(line_matches); // clean up match data
}
line_breakcheck();
diff --git a/test/functional/lua/buffer_updates_spec.lua b/test/functional/lua/buffer_updates_spec.lua
@@ -1250,6 +1250,255 @@ describe('lua: nvim_buf_attach on_bytes', function()
}
end)
+ it('on_bytes sees modified buffer after substitute', function()
+ api.nvim_buf_set_lines(0, 0, -1, true, { 'Hello' })
+
+ local buffer_lines = exec_lua(function()
+ local lines
+ vim.api.nvim_buf_attach(0, false, {
+ on_bytes = function()
+ lines = vim.api.nvim_buf_get_lines(0, 0, -1, true)
+ end,
+ })
+ vim.cmd('s/llo/y/')
+ return lines
+ end)
+
+ -- Make sure on_bytes is called after the buffer is modified.
+ eq({ 'Hey' }, buffer_lines)
+ end)
+
+ it('on_bytes called multiple times for multiple substitutions on same line', function()
+ api.nvim_buf_set_lines(0, 0, -1, true, { 'Hello Hello' })
+
+ local call_count, args = exec_lua(function()
+ local count = 0
+ local args = {}
+ vim.api.nvim_buf_attach(0, false, {
+ on_bytes = function(
+ _,
+ _,
+ _,
+ start_row,
+ start_col,
+ start_byte,
+ old_row,
+ old_col,
+ old_byte,
+ new_row,
+ new_col,
+ new_byte
+ )
+ count = count + 1
+ table.insert(args, {
+ start_row = start_row,
+ start_col = start_col,
+ start_byte = start_byte,
+ old_row = old_row,
+ old_col = old_col,
+ old_byte = old_byte,
+ new_row = new_row,
+ new_col = new_col,
+ new_byte = new_byte,
+ buffer_lines = vim.api.nvim_buf_get_lines(0, 0, -1, true),
+ })
+ end,
+ })
+ vim.cmd('s/llo/y/g')
+ return count, args
+ end)
+
+ -- Should be called twice, once for each match.
+ eq(2, call_count)
+
+ -- First match: "llo" at column 2 -> "y".
+ eq({
+ start_row = 0,
+ start_col = 2,
+ start_byte = 2,
+ old_row = 0,
+ old_col = 3,
+ old_byte = 3,
+ new_row = 0,
+ new_col = 1,
+ new_byte = 1,
+ buffer_lines = { 'Hey Hey' },
+ }, args[1])
+
+ -- Second match: "llo" at column 8 (in original) -> column 6 (after first substitution).
+ eq({
+ start_row = 0,
+ start_col = 6, -- Adjusted position after first substitution.
+ start_byte = 6,
+ old_row = 0,
+ old_col = 3,
+ old_byte = 3,
+ new_row = 0,
+ new_col = 1,
+ new_byte = 1,
+ buffer_lines = { 'Hey Hey' },
+ }, args[2])
+ end)
+
+ it('on_bytes called correctly for multi-line substitutions', function()
+ api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'baz qux' })
+
+ local call_count, args = exec_lua(function()
+ local count = 0
+ local args = {}
+ vim.api.nvim_buf_attach(0, false, {
+ on_bytes = function(
+ _,
+ _,
+ _,
+ start_row,
+ start_col,
+ start_byte,
+ old_row,
+ old_col,
+ old_byte,
+ new_row,
+ new_col,
+ new_byte
+ )
+ count = count + 1
+ table.insert(args, {
+ start_row = start_row,
+ start_col = start_col,
+ start_byte = start_byte,
+ old_row = old_row,
+ old_col = old_col,
+ old_byte = old_byte,
+ new_row = new_row,
+ new_col = new_col,
+ new_byte = new_byte,
+ buffer_lines = vim.api.nvim_buf_get_lines(0, 0, -1, true),
+ })
+ end,
+ })
+ vim.cmd('s/bar/X\\rY/')
+ return count, args
+ end)
+
+ -- Should be called once for the substitution.
+ eq(1, call_count)
+
+ eq({
+ start_row = 0,
+ start_col = 4,
+ start_byte = 4,
+ old_row = 0,
+ old_col = 3,
+ old_byte = 3,
+ new_row = 1,
+ new_col = 1,
+ new_byte = 3,
+ buffer_lines = { 'foo X', 'Y', 'baz qux' },
+ }, args[1])
+ end)
+
+ it('on_bytes called multiple times for global substitution creating multiple lines', function()
+ api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar baz' })
+
+ local call_count, args = exec_lua(function()
+ local count = 0
+ local args = {}
+ vim.api.nvim_buf_attach(0, false, {
+ on_bytes = function(
+ _,
+ _,
+ _,
+ start_row,
+ start_col,
+ start_byte,
+ old_row,
+ old_col,
+ old_byte,
+ new_row,
+ new_col,
+ new_byte
+ )
+ count = count + 1
+ table.insert(args, {
+ start_row = start_row,
+ start_col = start_col,
+ start_byte = start_byte,
+ old_row = old_row,
+ old_col = old_col,
+ old_byte = old_byte,
+ new_row = new_row,
+ new_col = new_col,
+ new_byte = new_byte,
+ buffer_lines = vim.api.nvim_buf_get_lines(0, 0, -1, true),
+ })
+ end,
+ })
+ -- Global substitution with newlines in replacement.
+ vim.cmd([[s/ /\r/g]])
+ return count, args
+ end)
+
+ -- Should be called once per space replacement.
+ eq(2, call_count)
+
+ eq({
+ start_row = 0,
+ start_col = 3,
+ start_byte = 3,
+ old_row = 0,
+ old_col = 1,
+ old_byte = 1,
+ new_row = 1,
+ new_col = 0,
+ new_byte = 1,
+ buffer_lines = { 'foo', 'bar', 'baz' },
+ }, args[1])
+
+ eq({
+ start_row = 1,
+ start_col = 3,
+ start_byte = 7,
+ old_row = 0,
+ old_col = 1,
+ old_byte = 1,
+ new_row = 1,
+ new_col = 0,
+ new_byte = 1,
+ buffer_lines = { 'foo', 'bar', 'baz' },
+ }, args[2])
+ end)
+
+ it(
+ 'no buffer update event is emitted while editing substitute command, only after confirmation',
+ function()
+ api.nvim_buf_set_lines(0, 0, -1, true, { 'Hello world', 'Hello Neovim' })
+
+ exec_lua(function()
+ _G.num_buffer_updates = 0
+ vim.api.nvim_buf_attach(0, false, {
+ on_bytes = function()
+ _G.num_buffer_updates = _G.num_buffer_updates + 1
+ end,
+ })
+ end)
+
+ -- Start typing the substitute command - no events should be emitted yet.
+ feed(':%s/Hello/Hi')
+ eq(0, exec_lua('return _G.num_buffer_updates'))
+
+ -- Continue editing the command - still no events.
+ feed('<BS><BS>Hey')
+ eq(0, exec_lua('return _G.num_buffer_updates'))
+
+ -- After confirming the substitution, two events should be emitted (one per line).
+ feed('<CR>')
+ eq(2, exec_lua('return _G.num_buffer_updates'))
+
+ -- Verify the buffer was actually modified.
+ eq({ 'Hey world', 'Hey Neovim' }, api.nvim_buf_get_lines(0, 0, -1, true))
+ end
+ )
+
it('flushes delbytes on join', function()
local check_events = setup_eventcheck(verify, { 'AAA', 'BBB', 'CCC' })