diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index d2f65e9..8e80620 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -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"', diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 43eb1f6..6a04d38 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -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,22 +195,33 @@ 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 hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - if hs then - file_old_start = tonumber(hs) - file_old_count = tonumber(hc) or 1 - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 + 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) + file_old_count = tonumber(hc) or 1 + file_new_start = tonumber(hs2) + file_new_count = tonumber(hc2) or 1 + end + 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 prefix, context = line:match('^(@@.-@@%s*)(.*)') + 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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 4064857..42cd0d1 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -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 @@', diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 32a3001..d88f9b7 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -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',