fix(marks): undo reverts a mark set after the change #41330

Problem:
A named mark updated after a change is moved back (treated as the
original mark) by undo:

    :1mark d
    :$
    dw
    :2mark d   " 'd is on line 2
    :undo      " 'd is back on line 1

The undo header snapshots `b_namedm` when the change is recorded, and
`u_undoredo()` restores that snapshot indiscriminately.

Solution:
Update the pending header's snapshot when a mark is set explicitly.
Marks that the change itself moved go through mark_adjust(), not
setmark_pos(), so those are still reverted.

Similar to 2546741d1b (for extmarks): an explicit set inside an undo
block is confused with an edit-driven adjustment. But the extmarks case
is dealing with mid-edit moves, whereas named/regular marks only need
the stale snapshot dropped.
This commit is contained in:
Justin M. Keyes
2026-08-15 13:34:51 -04:00
committed by GitHub
parent fb180287d0
commit 37c670e682
5 changed files with 54 additions and 0 deletions

View File

@@ -514,6 +514,9 @@ These existing features changed their behavior.
`opts.plain=true` and now does not expand leading tildes ("~") in addition
to environment variables ("expand_env" is still accepted, for backwards
compatibility).
• |undo| no longer restores the old (wrong) position of a mark that you moved
(|m|, |:mark|) later. The mark shifts with the text it was moved to, the
same as a mark the change never touched.
==============================================================================
REMOVED FEATURES *news-removed*

View File

@@ -682,6 +682,12 @@ Mappings:
- "#" followed by a digit doesn't stand for a function key at the start of the
lhs of a mapping.
Marks:
- |undo| does not restore a mark that you moved (|m|, |:mark|) after the change
being undone; the mark shifts with the text it was moved to, the same as a
mark the change never touched. Vim restores its old (wrong) position. Marks
moved by the change itself are still restored.
Motion:
- The |jumplist| avoids useless/phantom jumps.

View File

@@ -50,6 +50,7 @@
#include "nvim/tag.h"
#include "nvim/textobject.h"
#include "nvim/types_defs.h"
#include "nvim/undo.h"
#include "nvim/vim_defs.h"
// This file contains routines to maintain and manipulate marks.
@@ -184,6 +185,8 @@ 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);
// Moving a mark is not part of the pending change, so undo must not revert it. #5754
u_update_named_mark(buf, i);
do_markset_autocmd((char)c, pos, buf);
return OK;
}

View File

@@ -3235,6 +3235,19 @@ void f_undotree(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
tv_dict_add_list(dict, S_LEN("entries"), u_eval_tree(buf, buf->b_u_oldhead));
}
/// Drops named mark `idx` from the pending undo snapshot (if the user moved that mark after the
/// change was recorded, undo must not put it back).
///
/// The mark is then left to mark_adjust(), so undo shifts it with its text, like other marks not
/// touched by the change.
void u_update_named_mark(buf_T *buf, int idx)
{
u_header_T *uhp = buf->b_u_curhead != NULL ? buf->b_u_curhead : buf->b_u_newhead;
if (uhp != NULL) {
uhp->uh_namedm[idx].mark.lnum = 0;
}
}
// Given the buffer, Return the undo header. If none is set, set one first.
// NULL will be returned if e.g undolevels = -1 (undo disabled)
u_header_T *u_force_get_undo_header(buf_T *buf)

View File

@@ -41,6 +41,35 @@ describe('named marks', function()
eq({ 4, 0 }, api.nvim_buf_get_mark(0, 'c'))
end)
it('moved after a change follow their text through undo #5754', function()
command('edit ' .. file1)
command('1mark d')
command('$')
feed('dw')
command('2mark d')
eq({ 2, 0 }, api.nvim_buf_get_mark(0, 'd'))
command('undo')
eq({ 2, 0 }, api.nvim_buf_get_mark(0, 'd'))
command('redo')
eq({ 2, 0 }, api.nvim_buf_get_mark(0, 'd'))
-- A mark on a line the change deletes is cleared by the change; undo restores it.
api.nvim_buf_set_mark(0, 'e', 2, 2, {})
command('2delete')
eq({ 0, 2 }, api.nvim_buf_get_mark(0, 'e'))
command('undo')
eq({ 2, 2 }, api.nvim_buf_get_mark(0, 'e'))
eq('1test2', fn.getline(2))
-- A mark moved after a line-count change shifts with the text it was moved to.
command('2delete')
eq('1test3', fn.getline(2))
command('2mark f')
command('undo')
eq({ 3, 0 }, api.nvim_buf_get_mark(0, 'f'))
eq('1test3', fn.getline(3))
end)
it('errors when set out of range with :mark', function()
command('edit ' .. file1)
local err = pcall_err(n.exec_capture, '1000mark x')