vim-patch:9.1.1557: not possible to anchor specific lines in diff mode (#34967)

Problem:  not possible to anchor specific lines in diff mode
Solution: Add support for the anchoring lines in diff mode using the
          'diffanchor' option (Yee Cheng Chin).

Adds support for anchoring specific lines to each other while viewing a
diff. While lines are anchored, they are guaranteed to be aligned to
each other in a diff view, allowing the user to control and inform the
diff algorithm what the desired alignment is. Internally, this is done
by splitting up the buffer at each anchor and run the diff algorithm on
each split section separately, and then merge the results back for a
logically consistent diff result.

To do this, add a new "diffanchors" option that takes a list of
`{address}`, and a new "diffopt" option value "anchor". Each address
specified will be an anchor, and the user can choose to use any type of
address, including marks, line numbers, or pattern search. Anchors are
sorted by line number in each file, and it's possible to have multiple
anchors on the same line (this is useful when doing multi-buffer diff).
Update documentation to provide examples.

This is similar to Git diff's `--anchored` flag. Other diff tools like
Meld/Araxis Merge also have similar features (called "synchronization
points" or "synchronization links"). We are not using Git/Xdiff's
`--anchored` implementation here because it has a very limited API
(it requires usage of the Patience algorithm, and can only anchor
unique lines that are the same across both files).

Because the user could anchor anywhere, diff anchors could result in
adjacent diff blocks (one block is directly touching another without a
gap), if there is a change right above the anchor point. We don't want
to merge these diff blocks because we want to line up the change at the
anchor. Adjacent diff blocks were first allowed when linematch was
added, but the existing code had a lot of branched paths where
line-matched diff blocks were handled differently. As a part of this
change, refactor them to have a more unified code path that is
generalized enough to handle adjacent diff blocks correctly and without
needing to carve in exceptions all over the place.

closes: vim/vim#17615

0d9160e11c

Co-authored-by: Yee Cheng Chin <ychin.git@gmail.com>
This commit is contained in:
zeertzjq
2025-07-18 08:04:32 +08:00
committed by GitHub
parent e0d179561d
commit 4f0ab9877b
22 changed files with 1590 additions and 266 deletions

View File

@@ -2607,7 +2607,14 @@ func Test_linematch_diff()
call WriteDiffFiles(buf, ['// abc d?',
\ '// d?',
\ '// d?' ],
\ ['!',
\ 'abc d!',
\ 'd!'])
call term_sendkeys(buf, ":\<CR>") " clear cmdline
call VerifyScreenDump(buf, 'Test_linematch_diff1', {})
" test that filler is always implicitly set by linematch
call term_sendkeys(buf, ":set diffopt-=filler\<CR>")
call term_sendkeys(buf, ":\<CR>") " clear cmdline
call VerifyScreenDump(buf, 'Test_linematch_diff1', {})
@@ -2820,4 +2827,440 @@ func Test_linematch_3diffs_sanity_check()
let buf = RunVimInTerminal('-d -S Xlinematch_3diffs.vim Xfile_linematch1 Xfile_linematch2 Xfile_linematch3', {})
call VerifyScreenDump(buf, 'Test_linematch_3diffs2', {})
" clean up
call StopVimInTerminal(buf)
endfunc
func Test_diffanchors()
CheckScreendump
call WriteDiffFiles3(0,
\ ["anchorA1", "1", "2", "3",
\ "100", "101", "102", "anchorB", "103", "104", "105"],
\ ["100", "101", "102", "anchorB", "103", "104", "105",
\ "anchorA2", "1", "2", "3"],
\ ["100", "anchorB", "103",
\ "anchorA3", "1", "2", "3"])
let buf = RunVimInTerminal('-d Xdifile1 Xdifile2 Xdifile3', {})
" Simple diff without any anchors
call VerifyInternal(buf, "Test_diff_anchors_00", "")
" Setting diffopt+=anchor or diffanchors without the other won't do anything
call VerifyInternal(buf, "Test_diff_anchors_00", " diffopt+=anchor")
call VerifyInternal(buf, "Test_diff_anchors_00", " dia=1/anchorA/")
" Use a single anchor by specifying a pattern. Test both internal and
" external diff to make sure both paths work.
call VerifyBoth(buf, "Test_diff_anchors_01", " dia=1/anchorA/ diffopt+=anchor")
" Use 2 anchors. They should be sorted by line number, so in file 2/3
" anchorB is used before anchorA.
call VerifyBoth(buf, "Test_diff_anchors_02", " dia=1/anchorA/,1/anchorB/ diffopt+=anchor")
" Set marks and specify addresses using marks and repeat the test
call term_sendkeys(buf, ":2wincmd w\<CR>:1/anchorA/mark a\<CR>")
call term_sendkeys(buf, ":1/anchorB/mark b\<CR>")
call term_sendkeys(buf, ":3wincmd w\<CR>:1/anchorA/mark a\<CR>")
call term_sendkeys(buf, ":1/anchorB/mark b\<CR>")
call term_sendkeys(buf, ":1wincmd w\<CR>:1/anchorA/mark a\<CR>")
call term_sendkeys(buf, ":1/anchorB/mark b\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_01", " dia='a diffopt+=anchor")
call VerifyInternal(buf, "Test_diff_anchors_02", " dia='a,'b diffopt+=anchor")
" Update marks to point somewhere else. When we first set the mark the diff
" won't be updated until we manually invoke :diffupdate.
call VerifyInternal(buf, "Test_diff_anchors_01", " dia='a diffopt+=anchor")
call term_sendkeys(buf, ":1wincmd w\<CR>:1/anchorB/mark a\<CR>:")
call term_wait(buf)
call VerifyScreenDump(buf, "Test_diff_anchors_01", {})
call term_sendkeys(buf, ":diffupdate\<CR>:")
call term_wait(buf)
call VerifyScreenDump(buf, "Test_diff_anchors_03", {})
" Use local diff anchors with line numbers, and repeat the same test
call term_sendkeys(buf, ":2wincmd w\<CR>:setlocal dia=8\<CR>")
call term_sendkeys(buf, ":3wincmd w\<CR>:setlocal dia=4\<CR>")
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_01", " diffopt+=anchor")
call term_sendkeys(buf, ":2wincmd w\<CR>:setlocal dia=8,4\<CR>")
call term_sendkeys(buf, ":3wincmd w\<CR>:setlocal dia=4,2\<CR>")
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1,8\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_02", " diffopt+=anchor")
" Test multiple diff anchors on the same line in file 1.
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1,1\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_04", " diffopt+=anchor")
" Test that if one file has fewer diff anchors than others. Vim should only
" use the minimum in this case.
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=8\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_05", " diffopt+=anchor")
" $+1 should anchor everything past the last line
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=$+1\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_06", " diffopt+=anchor")
" Sorting of diff anchors should work with multiple anchors
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1,10,8,2\<CR>")
call term_sendkeys(buf, ":2wincmd w\<CR>:setlocal dia=1,2,3,4\<CR>")
call term_sendkeys(buf, ":3wincmd w\<CR>:setlocal dia=4,3,2,1\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_07", " diffopt+=anchor")
" Intentionally set an invalid anchor with wrong line number. Should fall
" back to treat it as if no anchors are used at all.
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1,10,8,2,1000 | silent! diffupdate\<CR>:")
call VerifyScreenDump(buf, "Test_diff_anchors_00", {})
call StopVimInTerminal(buf)
endfunc
" Test that scrollbind and topline calculations work correctly, even when diff
" anchors create adjacent diff blocks which complicates the calculations.
func Test_diffanchors_scrollbind_topline()
CheckScreendump
" Simple overlapped line anchored to be adjacent to each other
call WriteDiffFiles(0,
\ ["anchor1", "diff1a", "anchor2"],
\ ["anchor1", "diff2a", "anchor2"])
let buf = RunVimInTerminal('-d Xdifile1 Xdifile2', {})
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=2\<CR>")
call term_sendkeys(buf, ":2wincmd w\<CR>:setlocal dia=3\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_scrollbind_topline_01", " diffopt+=anchor")
call term_sendkeys(buf, "\<Esc>\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_02", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_03", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_04", {})
" Also test no-filler
call term_sendkeys(buf, "gg")
call VerifyInternal(buf, "Test_diff_anchors_scrollbind_topline_05", " diffopt+=anchor diffopt-=filler")
call term_sendkeys(buf, "\<Esc>\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_06", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_07", {})
call StopVimInTerminal(buf)
endfunc
func Test_diffanchors_scrollbind_topline2()
CheckScreendump
" More-complicated case with 3 files and multiple overlapping diff blocks
call WriteDiffFiles3(0,
\ ["anchor1"],
\ ["diff2a", "diff2b", "diff2c", "diff2d", "anchor2"],
\ ["diff3a", "diff3c", "diff3d", "anchor3", "diff3e"])
let buf = RunVimInTerminal('-d Xdifile1 Xdifile2 Xdifile3', {})
call term_sendkeys(buf, ":1wincmd w\<CR>:setlocal dia=1,1,2\<CR>")
call term_sendkeys(buf, ":2wincmd w\<CR>:setlocal dia=3,5,6\<CR>")
call term_sendkeys(buf, ":3wincmd w\<CR>:setlocal dia=2,4,5\<CR>")
call VerifyInternal(buf, "Test_diff_anchors_scrollbind_topline_08", " diffopt+=anchor")
call term_sendkeys(buf, ":1wincmd w\<CR>")
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_09", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_10", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_11", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_12", {})
" Also test no-filler
call term_sendkeys(buf, ":3wincmd w\<CR>gg")
call VerifyInternal(buf, "Test_diff_anchors_scrollbind_topline_13", " diffopt+=anchor diffopt-=filler")
call term_sendkeys(buf, "\<Esc>\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_14", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_15", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_16", {})
call term_sendkeys(buf, "\<C-E>")
call VerifyScreenDump(buf, "Test_diff_anchors_scrollbind_topline_17", {})
call StopVimInTerminal(buf)
endfunc
" Test that setting 'diffanchors' will update the diff.
func Test_diffanchors_option_set_update()
set diffanchors='a diffopt=internal,filler,anchor
" Set up 3 tabs that share some buffers, and set up marks on each of them.
" We want to make sure only relevant tabs are updated if buffer-local diff
" anchors are updated, but all tabs should refresh if global diff anchors
" are updated (see diffanchors_changed() in code).
" Tab 1. A buffer here will be reused.
call setline(1, range(1, 10))
3mark a
4mark b
diffthis
new
call setline(1, range(21, 25))
let buf = bufnr()
1mark a
2mark b
diffthis
call assert_equal(2, diff_filler(1))
call assert_equal(0, diff_filler(2))
" Tab 2. "buf" is here but intentionally not participating in diff.
tabnew
exec 'buf ' .. buf
diffoff
new
call setline(1, range(31, 40))
8mark a
9mark b
diffthis
new
call setline(1, range(41, 50))
5mark a
6mark b
diffthis
call assert_equal(3, diff_filler(5))
call assert_equal(0, diff_filler(6))
call assert_equal(0, diff_filler(7))
" Update mark a location, and check that the diff has *not* updated. When
" updating marks diff's won't automatically update.
7mark a
call assert_equal(3, diff_filler(5))
call assert_equal(0, diff_filler(6))
call assert_equal(0, diff_filler(7))
" Tab 3. "buf" is used here and also in a diff.
tabnew
call setline(1, range(51, 65))
10mark a
11mark b
diffthis
exec 'sbuffer ' .. buf
diffthis
" Change local diff anchor of "buf" to mark b
setlocal diffanchors='b
" Tab 1 should immediately update the diff to use mark b because the buf
" local diff anchor has been changed in "buf".
1tabnext
call assert_equal(0, diff_filler(1))
call assert_equal(1, diff_filler(2))
" Tab 2 should not immediately update because "buf" is not a diff buffer
" here.
2tabnext
call assert_equal(3, diff_filler(5))
call assert_equal(0, diff_filler(6))
call assert_equal(0, diff_filler(7))
" Manual diff update would refresh the diff since we previously changed mark
" a's location above.
diffupdate
call assert_equal(0, diff_filler(5))
call assert_equal(0, diff_filler(6))
call assert_equal(1, diff_filler(7))
" Go back to tab 1. Reset diff anchor to global value and make sure it uses
" mark a again.
1tabnext
set diffanchors<
call assert_equal(2, diff_filler(1))
call assert_equal(0, diff_filler(2))
" Now, change the global diff anchor to mark b. This should affect all tabs
" including tab 2 which should update automatically.
set diffanchors='b
call assert_equal(0, diff_filler(1))
call assert_equal(2, diff_filler(2))
2tabnext
call assert_equal(0, diff_filler(5))
call assert_equal(3, diff_filler(6))
call assert_equal(0, diff_filler(7))
%bw!
set diffopt&
set diffanchors&
endfunc
" Test that using diff anchors with window/buffer-local addresses will work as
" expected and use the relevant window/buffer instead of curbuf/curwin.
func Test_diffanchors_buf_win_local_addresses()
" Win 1-3 point to buffer 1. Set up different window-specific jump history
" Win 2 is the one we activate diff mode on.
call setline(1, range(1, 15))
norm 2gg
norm 3gg
split
norm 4gg
norm 5gg
split
norm 11gg
norm 12gg
call setline(10, 'new text 1') " update the '. mark to line 10
" Win 4 points to buffer 2
botright vert new
call setline(1, range(101, 110))
norm 8gg
norm 9gg
call setline(3, 'new text 2') " update the '. mark to line 3
2wincmd w
diffthis
4wincmd w
diffthis
" Test buffer-local marks using '. Should be anchored to lines 10 / 3.
set diffopt=internal,filler,anchor
set diffanchors='.
4wincmd w
call assert_equal(7, diff_filler(3))
" Test window-local marks using '' Should be anchored to lines 4 / 8.
" Note that windows 1 & 3 point to the buffer being diff'ed but are not used
" for diffing themselves and therefore should not be used. Windows 2 & 4
" should be used.
set diffanchors=''
2wincmd w
call assert_equal(4, diff_filler(4))
" Also test "." for the current cursor position, which is also
" window-specific. Make sure the cursor position at the longer file doesn't
" result in the other file using out of bounds line number.
4wincmd w
norm G
2wincmd w
norm G
set diffanchors=.
diffupdate
4wincmd w
call assert_equal(5, diff_filler(10))
%bw!
set diffopt&
set diffanchors&
endfunc
" Test diff anchors error handling for anchors that fail to resolve to a line.
" These are not handled during option parsing because they depend on the
" specifics of the buffer at diff time.
func Test_diffanchors_invalid()
call setline(1, range(1, 5))
new
call setline(1, range(11, 20))
set diffopt=internal,filler,anchor
windo diffthis
1wincmd w
" Line numbers that are out of bounds should be an error
set diffanchors=0
call assert_fails('diffupdate', 'E16:')
set diffanchors=1
diffupdate
set diffanchors=$
diffupdate
set diffanchors=$+1
diffupdate
set diffanchors=$+2
call assert_fails('diffupdate', 'E16:')
" Test that non-existent marks in any one buffer will be detected
set diffanchors='a
call assert_fails('diffupdate', 'E20:')
2mark a
call assert_fails('diffupdate', 'E20:')
set diffanchors=1
setlocal diffanchors='a
diffupdate
set diffanchors<
windo 2mark a
set diffanchors='b
call assert_fails('diffupdate', 'E20:')
set diffanchors='a
diffupdate
" File marks are ok to use for anchors only if it is in the same file
1wincmd w
3mark C
setlocal diffanchors='C
diffupdate
set diffanchors='C
call assert_fails('diffupdate', 'E20:')
" Buffer-local marks also can only be used in buffers that have them.
set diffanchors=1
exec "norm 1ggVj\<Esc>"
setlocal diffanchors='<
diffupdate
set diffanchors='<
call assert_fails('diffupdate', 'E20:')
" Pattern search that failed will be an error too
let @/='orig_search_pat'
set diffanchors=1/5/
diffupdate
call assert_equal('orig_search_pat', @/) " also check we don't pollute the search register
set diffanchors=1/does_not_exist/
call assert_fails('diffupdate', 'E1550:')
call assert_equal('orig_search_pat', @/)
%bw!
set diffopt&
set diffanchors&
endfunc
" Test diffget/diffput behaviors when using diff anchors which could create
" adjacent diff blocks.
func Test_diffget_diffput_diffanchors()
set diffanchors=1/anchor/
set diffopt=internal,filler,anchor
call setline(1, ['1', 'anchor1', '4'])
diffthis
new
call setline(1, ['2', '3', 'anchor2', '4', '5'])
diffthis
wincmd w
" Test using no-range diffget. It should grab the closest diff block only,
" even if there are multiple adjacent blocks.
2
diffget
call assert_equal(['1', 'anchor2', '4'], getline(1, '$'))
diffget
call assert_equal(['2', '3', 'anchor2', '4'], getline(1, '$'))
" Test using a range to get/put all the adjacent diff blocks.
1,$delete
call setline(1, ['anchor1', '4'])
0,1 diffget
call assert_equal(['2', '3', 'anchor2', '4'], getline(1, '$'))
1,$delete
call setline(1, ['anchor1', '4'])
0,$+1 diffget
call assert_equal(['2', '3', 'anchor2', '4', '5'], getline(1, '$'))
1,$delete
call setline(1, ['anchor1', '4'])
0,1 diffput
wincmd w
call assert_equal(['anchor1', '4', '5'], getline(1,'$'))
%bw!
set diffopt&
set diffanchors&