Commit: patch 9.2.1143: text property continuation wrong after undo/redo

2 views
Skip to first unread message

Christian Brabandt

unread,
Sep 28, 2026, 4:30:14 PM (4 days ago) Sep 28
to vim...@googlegroups.com
patch 9.2.1143: text property continuation wrong after undo/redo

Commit: https://github.com/vim/vim/commit/18ec0a76f0e44b6bbb94cb61f41594c84ba6c2bf
Author: Hirohito Higashi <h.eas...@gmail.com>
Date: Mon Sep 28 20:16:18 2026 +0000

patch 9.2.1143: text property continuation wrong after undo/redo

Problem: After undo or redo a text property may still be marked as
continuing to or from a line that is no longer next to it,
when the property was added after the change was saved
(david-k).
Solution: After undo and redo clear the continuation flags that have
no partner in the line above or below the restored lines,
and remember in the undo entry those cleared on the lines
around them, to put them back when the entry is applied the
other way.

Undo and redo restore the saved lines as they were, but a line just
outside of them may have got or lost a property since. The flags
cleared on that line are kept with the undo entry, not in the undo
file, so that undoing the redo joins the property again instead of
splitting it. The line itself is not touched, so marks and signs on
it stay where they are.

Add Test_prop_undo_redo_added_across_lines(), which checks
prop_list() after redo deletes the line where a property starts or
where it ends, and after undo brings the line back as one property
again.

fixes: #21350
closes: #21366

Assisted-by: Claude
Signed-off-by: Hirohito Higashi <h.eas...@gmail.com>
Signed-off-by: Christian Brabandt <c...@256bit.org>

diff --git a/src/proto/textprop.pro b/src/proto/textprop.pro
index 12c7d2876..645be29ff 100644
--- a/src/proto/textprop.pro
+++ b/src/proto/textprop.pro
@@ -36,6 +36,8 @@ void clear_buf_prop_types(buf_T *buf);
int adjust_prop_columns(linenr_T lnum, colnr_T col, int bytes_added, int flags);
void adjust_props_for_split(linenr_T lnum_props, linenr_T lnum_top, int kept, int deleted, int at_eol);
void prepend_joined_props(unpacked_memline_T *um, linenr_T lnum, int last_line, long col, int removed);
+void adjust_props_for_undo(buf_T *buf, linenr_T lnum, garray_T *gap, int above_which, int below_which);
+void restore_props_for_undo(buf_T *buf, linenr_T lnum, garray_T *gap, int which);
bool text_prop_count_valid(int prop_count, size_t propdata_len);
bool text_prop_vtext_valid(char_u *props, int prop_count, size_t propdata_len);
// vim: ft=c
diff --git a/src/structs.h b/src/structs.h
index 5a0cc2917..5525cdd3e 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -445,6 +445,25 @@ typedef struct {

typedef struct u_entry u_entry_T;
typedef struct u_header u_header_T;
+
+#ifdef FEAT_PROP_POPUP
+// Which line around an undo block a cleared continuation flag was on.
+# define UNDOPROP_NONE 0
+# define UNDOPROP_ABOVE 1
+# define UNDOPROP_BELOW 2
+
+// A continuation flag cleared on a line around an undo block, given back when
+// the block is undone or redone the other way.
+typedef struct
+{
+ int up_which; // UNDOPROP_ABOVE or UNDOPROP_BELOW
+ int up_type; // tp_type of the property
+ int up_id; // tp_id of the property
+ colnr_T up_col; // tp_col of the property
+ int up_flag; // TP_FLAG_CONT_NEXT or TP_FLAG_CONT_PREV
+} undoprop_T;
+#endif
+
struct u_entry
{
u_entry_T *ue_next; // pointer to next entry in list
@@ -453,6 +472,9 @@ struct u_entry
linenr_T ue_lcount; // linecount when u_save called
undoline_T *ue_array; // array of lines in undo block
long ue_size; // number of lines in ue_array
+#ifdef FEAT_PROP_POPUP
+ garray_T ue_props; // undoprop_T: flags cleared around the block
+#endif
#ifdef U_DEBUG
int ue_magic; // magic number to check allocation
#endif
diff --git a/src/testdir/test_textprop.vim b/src/testdir/test_textprop.vim
index 59ae9c58d..cd45f7c18 100644
--- a/src/testdir/test_textprop.vim
+++ b/src/testdir/test_textprop.vim
@@ -1161,6 +1161,55 @@ func Test_prop_undo()
call prop_type_delete('comment')
endfunc

+" A property added across the edge of the lines that undo/redo restores after
+" they were saved.
+func Test_prop_undo_redo_added_across_lines()
+ new
+ call prop_type_add('comment', {'highlight': 'Directory'})
+ call setline(1, ['one', 'two', 'three', 'four'])
+ set ul&
+ exe "normal 2Gdd"
+ undo
+
+ " Redo deletes the line where the property starts.
+ call prop_add(2, 1, {'end_lnum': 3, 'end_col': 6, 'type': 'comment'})
+ redo
+ call assert_equal(['one', 'three', 'four'], getline(1, '$'))
+ call assert_equal([#{lnum: 2, col: 1, length: 5, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 1, end: 1}],
+ \ prop_list(1, {'end_lnum': -1}))
+
+ " Undo brings back the line, and the property goes over two lines again.
+ undo
+ call assert_equal(['one', 'two', 'three', 'four'], getline(1, '$'))
+ call assert_equal([
+ \ #{lnum: 2, col: 1, length: 4, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 1, end: 0},
+ \ #{lnum: 3, col: 1, length: 5, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 0, end: 1}],
+ \ prop_list(1, {'end_lnum': -1}))
+ call prop_clear(1, 4)
+
+ " Redo deletes the line where the property ends.
+ call prop_add(1, 1, {'end_lnum': 2, 'end_col': 4, 'type': 'comment'})
+ redo
+ call assert_equal(['one', 'three', 'four'], getline(1, '$'))
+ call assert_equal([#{lnum: 1, col: 1, length: 4, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 1, end: 1}],
+ \ prop_list(1, {'end_lnum': -1}))
+ undo
+ call assert_equal(['one', 'two', 'three', 'four'], getline(1, '$'))
+ call assert_equal([
+ \ #{lnum: 1, col: 1, length: 4, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 1, end: 0},
+ \ #{lnum: 2, col: 1, length: 3, id: 0, type_bufnr: 0,
+ \ type: 'comment', start: 0, end: 1}],
+ \ prop_list(1, {'end_lnum': -1}))
+
+ bwipe!
+ call prop_type_delete('comment')
+endfunc
+
func Test_prop_delete_text()
new
call prop_type_add('comment', {'highlight': 'Directory'})
diff --git a/src/textprop.c b/src/textprop.c
index d35b1c006..36c39667e 100644
--- a/src/textprop.c
+++ b/src/textprop.c
@@ -3281,6 +3281,181 @@ prepend_joined_props(
um_abort(&r_um);
}

+/*
+ * Remember in "gap" that "flag" was cleared on "prop", on the line "which".
+ */
+ static void
+remember_cleared_prop(garray_T *gap, int which, textprop_T *prop, int flag)
+{
+ undoprop_T *up;
+
+ if (which == UNDOPROP_NONE)
+ return;
+ if (gap->ga_itemsize == 0)
+ ga_init2(gap, sizeof(undoprop_T), 4);
+ if (ga_grow(gap, 1) == FAIL)
+ return;
+ up = (undoprop_T *)gap->ga_data + gap->ga_len++;
+ up->up_which = which;
+ up->up_type = prop->tp_type;
+ up->up_id = prop->tp_id;
+ up->up_col = prop->tp_col;
+ up->up_flag = flag;
+}
+
+/*
+ * Clear the continuation flags between line "lnum" and the line below it that
+ * have no partner in the other line. A property is split there then. What
+ * is cleared on the line above is remembered in "gap" as "above_which", on
+ * the line below as "below_which".
+ */
+ void
+adjust_props_for_undo(
+ buf_T *buf,
+ linenr_T lnum,
+ garray_T *gap,
+ int above_which,
+ int below_which)
+{
+ unpacked_memline_T above;
+ unpacked_memline_T below;
+ bool *paired = NULL;
+ bool above_changed = false;
+ bool below_changed = false;
+ bool cont_next = false;
+
+ if (!buf->b_has_textprop || lnum < 1 || lnum >= buf->b_ml.ml_line_count)
+ return;
+
+ above = um_open_at(buf, lnum, 0);
+ if (above.buf == NULL)
+ return;
+ for (int i = 0; i < above.prop_count; ++i)
+ if (above.props[i].tp_flags & TP_FLAG_CONT_NEXT)
+ cont_next = true;
+ // Loading the line below may invalidate the text of "above", copy it when
+ // it may have to be stored.
+ if (cont_next && !um_detach(&above))
+ {
+ um_abort(&above);
+ return;
+ }
+
+ below = um_open_at(buf, lnum + 1, 0);
+ if (below.buf == NULL)
+ {
+ um_abort(&above);
+ return;
+ }
+ if (below.prop_count > 0)
+ {
+ paired = ALLOC_CLEAR_MULT(bool, below.prop_count);
+ if (paired == NULL)
+ goto theend;
+ }
+
+ for (int i = 0; i < above.prop_count; ++i)
+ {
+ textprop_T *prop = &above.props[i];
+ bool found = false;
+
+ if (!(prop->tp_flags & TP_FLAG_CONT_NEXT))
+ continue;
+ for (int j = 0; j < below.prop_count; ++j)
+ {
+ textprop_T *next = &below.props[j];
+
+ if (!paired[j] && (next->tp_flags & TP_FLAG_CONT_PREV)
+ && next->tp_id == prop->tp_id
+ && next->tp_type == prop->tp_type)
+ {
+ paired[j] = true;
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ {
+ prop->tp_flags &= ~TP_FLAG_CONT_NEXT;
+ above_changed = true;
+ remember_cleared_prop(gap, above_which, prop, TP_FLAG_CONT_NEXT);
+ }
+ }
+
+ for (int j = 0; j < below.prop_count; ++j)
+ {
+ if (!(below.props[j].tp_flags & TP_FLAG_CONT_PREV) || paired[j])
+ continue;
+ if (!below.detached && !um_detach(&below))
+ break;
+ below.props[j].tp_flags &= ~TP_FLAG_CONT_PREV;
+ below_changed = true;
+ remember_cleared_prop(gap, below_which, &below.props[j],
+ TP_FLAG_CONT_PREV);
+ }
+
+theend:
+ if (below_changed)
+ um_close(&below);
+ else
+ um_abort(&below);
+ if (above_changed)
+ um_close(&above);
+ else
+ um_abort(&above);
+ vim_free(paired);
+}
+
+/*
+ * Give back to line "lnum" the continuation flags "gap" has for "which",
+ * those cleared when the undo block was applied the other way. A property
+ * that is no longer there is skipped.
+ */
+ void
+restore_props_for_undo(
+ buf_T *buf,
+ linenr_T lnum,
+ garray_T *gap,
+ int which)
+{
+ unpacked_memline_T um;
+ bool changed = false;
+
+ if (!buf->b_has_textprop || gap->ga_len == 0 || lnum < 1
+ || lnum > buf->b_ml.ml_line_count)
+ return;
+ um = um_open_at(buf, lnum, 0);
+ if (um.buf == NULL)
+ return;
+ for (int r = 0; r < gap->ga_len; ++r)
+ {
+ undoprop_T *up = (undoprop_T *)gap->ga_data + r;
+
+ if (up->up_which != which)
+ continue;
+ for (int i = 0; i < um.prop_count; ++i)
+ {
+ textprop_T *prop = &um.props[i];
+
+ if (prop->tp_type != up->up_type || prop->tp_id != up->up_id
+ || prop->tp_col != up->up_col
+ || (prop->tp_flags & up->up_flag))
+ continue;
+ if (!um.detached && !um_detach(&um))
+ goto theend;
+ um.props[i].tp_flags |= up->up_flag;
+ changed = true;
+ break;
+ }
+ }
+
+theend:
+ if (changed)
+ um_close(&um);
+ else
+ um_abort(&um);
+}
+
bool
text_prop_count_valid(int prop_count, size_t propdata_len)
{
diff --git a/src/undo.c b/src/undo.c
index 02e475d48..8cb261df0 100644
--- a/src/undo.c
+++ b/src/undo.c
@@ -2863,6 +2863,22 @@ u_undoredo(int undo)
vim_free((char_u *)uep->ue_array);
}

+#ifdef FEAT_PROP_POPUP
+ // A property may have been added or removed across the edges of the
+ // restored lines after they were saved. What was cleared on the lines
+ // around them the last time is given back first, so that undoing and
+ // redoing again joins what it split.
+ restore_props_for_undo(curbuf, top, &uep->ue_props, UNDOPROP_ABOVE);
+ restore_props_for_undo(curbuf, top + newsize + 1, &uep->ue_props,
+ UNDOPROP_BELOW);
+ ga_clear(&uep->ue_props);
+ adjust_props_for_undo(curbuf, top, &uep->ue_props, UNDOPROP_ABOVE,
+ newsize > 0 ? UNDOPROP_NONE : UNDOPROP_BELOW);
+ if (newsize > 0)
+ adjust_props_for_undo(curbuf, top + newsize, &uep->ue_props,
+ UNDOPROP_NONE, UNDOPROP_BELOW);
+#endif
+
// adjust marks
if (oldsize != newsize)
{
@@ -3506,6 +3522,9 @@ u_freeentry(u_entry_T *uep, long n)
while (n > 0)
vim_free(uep->ue_array[--n].ul_line);
vim_free((char_u *)uep->ue_array);
+#ifdef FEAT_PROP_POPUP
+ ga_clear(&uep->ue_props);
+#endif
#ifdef U_DEBUG
uep->ue_magic = 0;
#endif
diff --git a/src/version.c b/src/version.c
index 293edecec..fa90186c3 100644
--- a/src/version.c
+++ b/src/version.c
@@ -763,6 +763,8 @@ static char *(features[]) =

static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 1143,
/**/
1142,
/**/
Reply all
Reply to author
Forward
0 new messages