fix(highlight): support combined diff format for unmerged files (#106)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions

## Problem

Fugitive shows combined diffs (`@@@` headers, 2-character prefixes like
`++`, ` +`, `+ `) for unmerged (`UU`) files. The parser and highlight
pipeline assumed unified diff format (`@@`, 1-char prefix), causing:

- Prefix concealment only hiding 1 of 2 prefix chars
- Missing background colors on ` +` and `+ ` lines (first char is space
→ misclassified as context)
- No treesitter highlights (extra prefix char poisoned code arrays)
- `U` file header not recognized by parser (missing from filename
pattern)

## Solution

Detect prefix width from leading `@` count in hunk headers (`@@` → 1,
`@@@` → 2). Propagate `prefix_width` through the pipeline:

- **Parser**: new `prefix_width` field on `diffs.Hunk`, `U` added to
filename pattern, combined diff range extraction
- **Highlight**: prefix stripping, `col_offset`, concealment width, and
line classification all use `prefix_width`
- **Intra-line**: skipped for combined diffs (`prefix_width > 1`) since
2-char prefix semantics don't produce meaningful change groups
This commit is contained in:
Barrett Ruth 2026-02-09 19:30:13 -05:00 committed by GitHub
parent 59fcf14817
commit cc5a368838
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 484 additions and 28 deletions

View file

@ -239,13 +239,14 @@ local function highlight_vim_syntax(
pcall(vim.api.nvim_buf_delete, scratch, { force = true })
local hunk_line_count = #hunk.lines
local col_off = (hunk.prefix_width or 1) - 1
local extmark_count = 0
for _, span in ipairs(spans) do
local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, {
end_col = span.col_end + col_off,
hl_group = span.hl_name,
priority = priorities.syntax,
})
@ -265,6 +266,7 @@ end
---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
local use_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@ -296,14 +298,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
local old_map = {}
for i, line in ipairs(hunk.lines) do
local prefix = line:sub(1, 1)
local stripped = line:sub(2)
local prefix = line:sub(1, pw)
local stripped = line:sub(pw + 1)
local buf_line = hunk.start_line + i - 1
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
if prefix == '+' then
if has_add and not has_del then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
elseif prefix == '-' then
elseif has_del and not has_add then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
else
@ -314,9 +318,9 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
extmark_count =
highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines, p)
highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p)
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines, p)
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p)
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
@ -344,7 +348,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type string[]
local code_lines = {}
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
table.insert(code_lines, line:sub(pw + 1))
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
end
@ -367,7 +371,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type diffs.IntraChanges?
local intra = nil
local intra_cfg = opts.highlights.intra
if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then
if intra_cfg and intra_cfg.enabled and pw == 1 and #hunk.lines <= intra_cfg.max_lines then
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines)
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
if intra then
@ -401,22 +405,32 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
local prefix = line:sub(1, 1)
local prefix = line:sub(1, pw)
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
local is_diff_line = has_add or has_del
local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local is_diff_line = prefix == '+' or prefix == '-'
local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local is_marker = false
if pw > 1 and line_hl and not prefix:find('[^+]') then
local content = line:sub(pw + 1)
is_marker = content:match('^<<<<<<<')
or content:match('^=======')
or content:match('^>>>>>>>')
or content:match('^|||||||')
end
if opts.hide_prefix then
local virt_hl = (opts.highlights.background and line_hl) or nil
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
virt_text = { { ' ', virt_hl } },
virt_text = { { string.rep(' ', pw), virt_hl } },
virt_text_pos = 'overlay',
})
end
if line_len > 1 and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
if line_len > pw and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len,
hl_group = 'DiffsClear',
priority = p.clear,
@ -438,8 +452,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
if is_marker and line_len > pw then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len,
hl_group = 'DiffsConflictMarker',
priority = p.char_bg,
})
end
if char_spans_by_line[i] then
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do
dbg(
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',

View file

@ -12,6 +12,7 @@
---@field file_old_count integer?
---@field file_new_start integer?
---@field file_new_count integer?
---@field prefix_width integer
---@field repo_root string?
local M = {}
@ -133,6 +134,8 @@ function M.parse_buffer(bufnr)
local hunk_lines = {}
---@type integer?
local hunk_count = nil
---@type integer
local hunk_prefix_width = 1
---@type integer?
local header_start = nil
---@type string[]
@ -156,6 +159,7 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context,
header_context_col = hunk_header_context_col,
lines = hunk_lines,
prefix_width = hunk_prefix_width,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
@ -179,7 +183,7 @@ function M.parse_buffer(bufnr)
end
for i, line in ipairs(lines) do
local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
if filename then
flush_hunk()
current_filename = filename
@ -191,11 +195,15 @@ function M.parse_buffer(bufnr)
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end
hunk_count = 0
hunk_prefix_width = 1
header_start = i
header_lines = {}
elseif line:match('^@@.-@@') then
elseif line:match('^@@+') then
flush_hunk()
hunk_start = i
local at_prefix = line:match('^(@@+)')
hunk_prefix_width = #at_prefix - 1
if #at_prefix == 2 then
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
@ -203,10 +211,17 @@ function M.parse_buffer(bufnr)
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
else
local hs2, hc2 = line:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
end
local at_end, context = line:match('^(@@+.-@@+%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
hunk_header_context_col = #prefix
hunk_header_context_col = #at_end
end
if hunk_count then
hunk_count = hunk_count + 1

View file

@ -13,6 +13,7 @@ describe('highlight', function()
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true })
end)
local function create_buffer(lines)
@ -830,6 +831,307 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('classifies all combined diff prefix types for background', function()
local bufnr = create_buffer({
'@@@ -1,5 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'+ local greeting = "hi"',
'++=======',
'+ return 2',
'++>>>>>>> feature',
' end',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = {
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'+ local greeting = "hi"',
'++=======',
'+ return 2',
'++>>>>>>> feature',
' end',
},
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
local line_bgs = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_eol then
line_bgs[mark[2]] = mark[4].hl_group
end
end
assert.is_nil(line_bgs[1])
assert.are.equal('DiffsAdd', line_bgs[2])
assert.are.equal('DiffsAdd', line_bgs[3])
assert.are.equal('DiffsAdd', line_bgs[4])
assert.are.equal('DiffsAdd', line_bgs[5])
assert.are.equal('DiffsAdd', line_bgs[6])
assert.are.equal('DiffsAdd', line_bgs[7])
assert.is_nil(line_bgs[8])
delete_buffer(bufnr)
end)
it('conceals full 2-char prefix for all combined diff line types', function()
local bufnr = create_buffer({
'@@@ -1,3 -1,3 +1,5 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'+ local x = 2',
' end',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = {
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'+ local x = 2',
' end',
},
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true }))
local extmarks = get_extmarks(bufnr)
local overlays = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text_pos == 'overlay' then
overlays[mark[2]] = mark[4].virt_text[1][1]
end
end
assert.are.equal(5, vim.tbl_count(overlays))
for _, text in pairs(overlays) do
assert.are.equal(' ', text)
end
delete_buffer(bufnr)
end)
it('places treesitter captures at col_offset 2 for combined diffs', function()
local bufnr = create_buffer({
'@@@ -1,2 -1,2 +1,2 @@@',
' local x = 1',
' +local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = { ' local x = 1', ' +local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local ts_marks = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
table.insert(ts_marks, mark)
end
end
assert.is_true(#ts_marks > 0)
for _, mark in ipairs(ts_marks) do
assert.is_true(mark[3] >= 2)
end
delete_buffer(bufnr)
end)
it('applies DiffsClear starting at col 2 for combined diffs', function()
local bufnr = create_buffer({
'@@@ -1,1 -1,1 +1,2 @@@',
' local x = 1',
' +local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = { ' local x = 1', ' +local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsClear' then
assert.are.equal(2, mark[3])
end
end
delete_buffer(bufnr)
end)
it('skips intra-line diffing for combined diffs', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
local bufnr = create_buffer({
'@@@ -1,2 -1,2 +1,3 @@@',
' local x = 1',
' +local y = 2',
'+ local y = 3',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = { ' local x = 1', ' +local y = 2', '+ local y = 3' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
assert.is_not_equal('DiffsAddText', d and d.hl_group)
assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
end
delete_buffer(bufnr)
end)
it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function()
local bufnr = create_buffer({
'@@@ -1,5 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
'+ local x = 1',
'++||||||| base',
'++=======',
' +local y = 2',
'++>>>>>>> feature',
' return M',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = {
' local M = {}',
'++<<<<<<< HEAD',
'+ local x = 1',
'++||||||| base',
'++=======',
' +local y = 2',
'++>>>>>>> feature',
' return M',
},
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true, gutter = true } })
)
local extmarks = get_extmarks(bufnr)
local line_bgs = {}
local gutter_hls = {}
local marker_text = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_eol then
line_bgs[mark[2]] = d.hl_group
end
if d and d.number_hl_group then
gutter_hls[mark[2]] = d.number_hl_group
end
if d and d.hl_group == 'DiffsConflictMarker' then
marker_text[mark[2]] = true
end
end
assert.is_nil(line_bgs[1])
assert.are.equal('DiffsAdd', line_bgs[2])
assert.are.equal('DiffsAdd', line_bgs[3])
assert.are.equal('DiffsAdd', line_bgs[4])
assert.are.equal('DiffsAdd', line_bgs[5])
assert.are.equal('DiffsAdd', line_bgs[6])
assert.are.equal('DiffsAdd', line_bgs[7])
assert.is_nil(line_bgs[8])
assert.is_nil(gutter_hls[1])
assert.are.equal('DiffsAddNr', gutter_hls[2])
assert.are.equal('DiffsAddNr', gutter_hls[3])
assert.are.equal('DiffsAddNr', gutter_hls[4])
assert.are.equal('DiffsAddNr', gutter_hls[5])
assert.are.equal('DiffsAddNr', gutter_hls[6])
assert.are.equal('DiffsAddNr', gutter_hls[7])
assert.is_nil(gutter_hls[8])
assert.is_true(marker_text[2] ~= nil)
assert.is_nil(marker_text[3])
assert.is_true(marker_text[4] ~= nil)
assert.is_true(marker_text[5] ~= nil)
assert.is_nil(marker_text[6])
assert.is_true(marker_text[7] ~= nil)
delete_buffer(bufnr)
end)
it('does not apply DiffsConflictMarker in unified diffs', function()
local bufnr = create_buffer({
'@@ -1,1 +1,4 @@',
' local M = {}',
'+<<<<<<< HEAD',
'+local x = 1',
'+=======',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
assert.is_not_equal('DiffsConflictMarker', d and d.hl_group)
end
delete_buffer(bufnr)
end)
it('filters @spell and @nospell captures from injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',

View file

@ -425,6 +425,123 @@ describe('parser', function()
delete_buffer(bufnr)
end)
it('recognizes U prefix for unmerged files', function()
local bufnr = create_buffer({
'U merge_me.lua',
'@@@ -1,3 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'++=======',
'+ return 2',
'++>>>>>>> feature',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('merge_me.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('sets prefix_width 2 from @@@ combined diff header', function()
local bufnr = create_buffer({
'U test.lua',
'@@@ -1,3 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(2, hunks[1].prefix_width)
delete_buffer(bufnr)
end)
it('sets prefix_width 1 for standard @@ unified diff', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,3 @@',
' local x = 1',
'+local y = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(1, hunks[1].prefix_width)
delete_buffer(bufnr)
end)
it('collects all combined diff line types as hunk content', function()
local bufnr = create_buffer({
'U test.lua',
'@@@ -1,3 -1,3 +1,5 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'+ local x = 2',
' end',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(5, #hunks[1].lines)
assert.are.equal(' local M = {}', hunks[1].lines[1])
assert.are.equal('++<<<<<<< HEAD', hunks[1].lines[2])
assert.are.equal(' + return 1', hunks[1].lines[3])
assert.are.equal('+ local x = 2', hunks[1].lines[4])
assert.are.equal(' end', hunks[1].lines[5])
delete_buffer(bufnr)
end)
it('extracts new range from combined diff header', function()
local bufnr = create_buffer({
'U test.lua',
'@@@ -1,3 -1,5 +1,9 @@@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(1, hunks[1].file_new_start)
assert.are.equal(9, hunks[1].file_new_count)
assert.is_nil(hunks[1].file_old_start)
delete_buffer(bufnr)
end)
it('extracts header context from combined diff header', function()
local bufnr = create_buffer({
'U test.lua',
'@@@ -1,3 -1,5 +1,9 @@@ function M.greet()',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('function M.greet()', hunks[1].header_context)
delete_buffer(bufnr)
end)
it('resets prefix_width when switching from combined to unified diff', function()
local bufnr = create_buffer({
'U merge.lua',
'@@@ -1,1 -1,1 +1,3 @@@',
' local M = {}',
'++<<<<<<< HEAD',
'M other.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(2, #hunks)
assert.are.equal(2, hunks[1].prefix_width)
assert.are.equal(1, hunks[2].prefix_width)
delete_buffer(bufnr)
end)
it('stores repo_root on hunk when available', function()
local bufnr = create_buffer({
'M lua/test.lua',