From 49fc446aaeb2743c329bec31966d3de23b8c6cbf Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:29:39 -0500 Subject: [PATCH 01/55] doc: add plug mappings for merge conflict resolution (#98) --- doc/diffs.nvim.txt | 35 +++++++++++++++++++++++++++++++++++ lua/diffs/conflict.lua | 42 +++++++++++++----------------------------- plugin/diffs.lua | 25 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 028d8ba..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -280,6 +280,41 @@ Example configuration: >lua vim.keymap.set('n', 'gD', '(diffs-gvdiff)') < + *(diffs-conflict-ours)* +(diffs-conflict-ours) + Accept current (ours) change. Replaces the + conflict block with ours content. + + *(diffs-conflict-theirs)* +(diffs-conflict-theirs) + Accept incoming (theirs) change. Replaces the + conflict block with theirs content. + + *(diffs-conflict-both)* +(diffs-conflict-both) + Accept both changes (ours then theirs). + + *(diffs-conflict-none)* +(diffs-conflict-none) + Reject both changes (delete entire block). + + *(diffs-conflict-next)* +(diffs-conflict-next) + Jump to next conflict marker. Wraps around. + + *(diffs-conflict-prev)* +(diffs-conflict-prev) + Jump to previous conflict marker. Wraps around. + +Example configuration: >lua + vim.keymap.set('n', 'co', '(diffs-conflict-ours)') + vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') + vim.keymap.set('n', 'cb', '(diffs-conflict-both)') + vim.keymap.set('n', 'cn', '(diffs-conflict-none)') + vim.keymap.set('n', ']x', '(diffs-conflict-next)') + vim.keymap.set('n', '[x', '(diffs-conflict-prev)') +< + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f4468d2..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -348,35 +348,19 @@ end local function setup_keymaps(bufnr, config) local km = config.keymaps - if km.ours then - vim.keymap.set('n', km.ours, function() - M.resolve_ours(bufnr, config) - end, { buffer = bufnr }) - end - if km.theirs then - vim.keymap.set('n', km.theirs, function() - M.resolve_theirs(bufnr, config) - end, { buffer = bufnr }) - end - if km.both then - vim.keymap.set('n', km.both, function() - M.resolve_both(bufnr, config) - end, { buffer = bufnr }) - end - if km.none then - vim.keymap.set('n', km.none, function() - M.resolve_none(bufnr, config) - end, { buffer = bufnr }) - end - if km.next then - vim.keymap.set('n', km.next, function() - M.goto_next(bufnr) - end, { buffer = bufnr }) - end - if km.prev then - vim.keymap.set('n', km.prev, function() - M.goto_prev(bufnr) - end, { buffer = bufnr }) + local maps = { + { km.ours, '(diffs-conflict-ours)' }, + { km.theirs, '(diffs-conflict-theirs)' }, + { km.both, '(diffs-conflict-both)' }, + { km.none, '(diffs-conflict-none)' }, + { km.next, '(diffs-conflict-next)' }, + { km.prev, '(diffs-conflict-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end end end diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e4fc690..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -57,3 +57,28 @@ end, { desc = 'Unified diff (horizontal)' }) vim.keymap.set('n', '(diffs-gvdiff)', function() cmds.gdiff(nil, true) end, { desc = 'Unified diff (vertical)' }) + +local function conflict_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-conflict-ours)', function() + conflict_action(require('diffs.conflict').resolve_ours) +end, { desc = 'Accept current (ours) change' }) +vim.keymap.set('n', '(diffs-conflict-theirs)', function() + conflict_action(require('diffs.conflict').resolve_theirs) +end, { desc = 'Accept incoming (theirs) change' }) +vim.keymap.set('n', '(diffs-conflict-both)', function() + conflict_action(require('diffs.conflict').resolve_both) +end, { desc = 'Accept both changes' }) +vim.keymap.set('n', '(diffs-conflict-none)', function() + conflict_action(require('diffs.conflict').resolve_none) +end, { desc = 'Reject both changes' }) +vim.keymap.set('n', '(diffs-conflict-next)', function() + require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict' }) +vim.keymap.set('n', '(diffs-conflict-prev)', function() + require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict' }) From a2053a132bc0cccf5ab7d9247c047ea90beddd9d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:21:13 -0500 Subject: [PATCH 02/55] feat: unified diff conflict resolution for unmerged files (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Pressing `du` on a `UU` (unmerged) file in the fugitive status buffer had no effect. There was no way to see a proper ours-vs-theirs diff with syntax highlighting and intra-line changes, or to resolve conflicts from within a unified diff view. Additionally, pressing `du` on a section header containing only unmerged files showed "no changes in section" because `git diff` produces combined (`diff --cc`) output for unmerged files, which was stripped entirely. ## Solution Fetch `:2:` (ours) and `:3:` (theirs) from the git index and generate a standard unified diff. The existing highlight pipeline (treesitter + intra-line) applies automatically. Resolution keymaps (`doo`/`dot`/`dob`/`don`) on hunks in the diff view write changes back to the working file's conflict markers. Navigation (`]x`/`[x`) jumps between unresolved conflict hunks. For section diffs, combined diff entries are now replaced with generated ours-vs-theirs unified diffs instead of being stripped. Works for merge, cherry-pick, and rebase conflicts — git populates `:2:`/`:3:` the same way for all three. Closes #61 --- doc/diffs.nvim.txt | 52 ++++ lua/diffs/commands.lua | 75 ++++- lua/diffs/conflict.lua | 22 +- lua/diffs/fugitive.lua | 31 +- lua/diffs/merge.lua | 374 +++++++++++++++++++++++ plugin/diffs.lua | 25 ++ spec/commands_spec.lua | 72 +++++ spec/merge_spec.lua | 664 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1287 insertions(+), 28 deletions(-) create mode 100644 lua/diffs/merge.lua create mode 100644 spec/merge_spec.lua diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 7663150..6bd848c 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -315,6 +315,32 @@ Example configuration: >lua vim.keymap.set('n', '[x', '(diffs-conflict-prev)') < + *(diffs-merge-ours)* +(diffs-merge-ours) + Accept ours in a merge diff view. Resolves the + conflict in the working file with ours content. + + *(diffs-merge-theirs)* +(diffs-merge-theirs) + Accept theirs in a merge diff view. + + *(diffs-merge-both)* +(diffs-merge-both) + Accept both (ours then theirs) in a merge diff view. + + *(diffs-merge-none)* +(diffs-merge-none) + Reject both in a merge diff view. + + *(diffs-merge-next)* +(diffs-merge-next) + Jump to next unresolved conflict hunk in merge diff. + + *(diffs-merge-prev)* +(diffs-merge-prev) + Jump to previous unresolved conflict hunk in merge + diff. + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` @@ -345,6 +371,7 @@ Behavior by file status: ~ A Staged (empty) index file as all-added D Staged HEAD (empty) file as all-removed R Staged HEAD:oldname index:newname content diff + U Unstaged :2: (ours) :3: (theirs) merge diff ? Untracked (empty) working tree file as all-added On section headers, the keymap runs `git diff` (or `git diff --cached` for @@ -458,6 +485,31 @@ User events: ~ }) < +============================================================================== +MERGE DIFF RESOLUTION *diffs-merge* + +When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status +buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs +theirs (`git show :3:path`) with full treesitter and intra-line highlighting. + +The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]x`/`[x`) +are available on the diff buffer. They resolve conflicts in the working +file by matching diff hunks to conflict markers: + +- `doo` replaces the conflict region with ours content +- `dot` replaces the conflict region with theirs content +- `dob` replaces with both (ours then theirs) +- `don` removes the conflict region entirely +- `]x`/`[x` navigate between unresolved conflict hunks + +Resolved hunks are marked with `(resolved)` virtual text. Hunks that +correspond to auto-merged content (no conflict markers) show an +informational notification and are left unchanged. + +The working file buffer is modified in place; save it when ready. +Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed +automatically after each resolution. + ============================================================================== API *diffs-api* diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index dc6cfe2..01f1d3a 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -36,6 +36,24 @@ function M.find_hunk_line(diff_lines, hunk_position) return nil end +---@param lines string[] +---@return string[] +function M.filter_combined_diffs(lines) + local result = {} + local skip = false + for _, line in ipairs(lines) do + if line:match('^diff %-%-cc ') then + skip = true + elseif line:match('^diff %-%-git ') then + skip = false + end + if not skip then + table.insert(result, line) + end + end + return result +end + ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -69,6 +87,33 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name) return result end +---@param raw_lines string[] +---@param repo_root string +---@return string[] +local function replace_combined_diffs(raw_lines, repo_root) + local unmerged_files = {} + for _, line in ipairs(raw_lines) do + local cc_file = line:match('^diff %-%-cc (.+)$') + if cc_file then + table.insert(unmerged_files, cc_file) + end + end + + local result = M.filter_combined_diffs(raw_lines) + + for _, filename in ipairs(unmerged_files) do + local filepath = repo_root .. '/' .. filename + local old_lines = git.get_file_content(':2', filepath) or {} + local new_lines = git.get_file_content(':3', filepath) or {} + local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename) + for _, dl in ipairs(diff_lines) do + table.insert(result, dl) + end + end + + return result +end + ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) @@ -138,6 +183,7 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean +---@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } @@ -157,7 +203,17 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.untracked then + if opts.unmerged then + old_lines = git.get_file_content(':2', filepath) + if not old_lines then + old_lines = {} + end + new_lines = git.get_file_content(':3', filepath) + if not new_lines then + new_lines = {} + end + diff_label = 'unmerged' + elseif opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then @@ -236,6 +292,14 @@ function M.gdiff_file(filepath, opts) end M.setup_diff_buf(diff_buf) + + if diff_label == 'unmerged' then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) + local conflict_config = require('diffs').get_conflict_config() + require('diffs.merge').setup_keymaps(diff_buf, conflict_config) + end + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -263,6 +327,8 @@ function M.gdiff_section(repo_root, opts) return end + result = replace_combined_diffs(result, repo_root) + if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return @@ -325,6 +391,8 @@ function M.read_buffer(bufnr) if vim.v.shell_error ~= 0 then diff_lines = {} end + + diff_lines = replace_combined_diffs(diff_lines, repo_root) else local abs_path = repo_root .. '/' .. path @@ -334,7 +402,10 @@ function M.read_buffer(bufnr) local old_lines, new_lines - if label == 'untracked' then + if label == 'unmerged' then + old_lines = git.get_file_content(':2', abs_path) or {} + new_lines = git.get_file_content(':3', abs_path) or {} + elseif label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 9e62a15..894c0b8 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -199,7 +199,7 @@ end ---@param bufnr integer ---@param region diffs.ConflictRegion ---@param replacement string[] -local function replace_region(bufnr, region, replacement) +function M.replace_region(bufnr, region, replacement) vim.api.nvim_buf_set_lines( bufnr, region.marker_ours, @@ -211,7 +211,7 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig -local function refresh(bufnr, config) +function M.refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) @@ -244,8 +244,8 @@ function M.resolve_ours(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -262,8 +262,8 @@ function M.resolve_theirs(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -288,8 +288,8 @@ function M.resolve_both(bufnr, config) for _, l in ipairs(theirs) do table.insert(combined, l) end - replace_region(bufnr, region, combined) - refresh(bufnr, config) + M.replace_region(bufnr, region, combined) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -305,8 +305,8 @@ function M.resolve_none(bufnr, config) if not region then return end - replace_region(bufnr, region, {}) - refresh(bufnr, config) + M.replace_region(bufnr, region, {}) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -417,7 +417,7 @@ function M.attach(bufnr, config) if not attached_buffers[bufnr] then return true end - refresh(bufnr, config) + M.refresh(bufnr, config) end, }) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index a588a22..b11985c 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -27,19 +27,19 @@ function M.get_section_at_line(bufnr, lnum) end ---@param line string ----@return string?, string? +---@return string?, string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return vim.trim(new), vim.trim(old) + return vim.trim(new), vim.trim(old), 'R' end - local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') - if filename then - return vim.trim(filename), nil + local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') + if status and filename then + return vim.trim(filename), nil, status end - return nil, nil + return nil, nil, nil end ---@param line string @@ -57,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string? +---@return string?, diffs.FugitiveSection, boolean, string?, string? function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil, false, nil + return nil, nil, false, nil, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true, nil + return nil, section_header, true, nil, nil end - local filename, old_filename = parse_file_line(current_line) + local filename, old_filename, status = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false, old_filename + return filename, section, false, old_filename, status end local prefix = current_line:sub(1, 1) if prefix == '+' or prefix == '-' or prefix == ' ' then for i = lnum - 1, 1, -1 do local prev_line = lines[i] - filename, old_filename = parse_file_line(prev_line) + filename, old_filename, status = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename + return filename, section, false, old_filename, status end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -92,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false, nil + return nil, nil, false, nil, nil end ---@class diffs.HunkPosition @@ -150,7 +150,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -192,6 +192,7 @@ function M.diff_file_under_cursor(vertical) vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', + unmerged = status == 'U', old_filepath = old_filepath, hunk_position = hunk_position, }) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua new file mode 100644 index 0000000..9bb9d68 --- /dev/null +++ b/lua/diffs/merge.lua @@ -0,0 +1,374 @@ +local M = {} + +local conflict = require('diffs.conflict') + +local ns = vim.api.nvim_create_namespace('diffs-merge') + +---@type table> +local resolved_hunks = {} + +---@class diffs.MergeHunkInfo +---@field index integer +---@field start_line integer +---@field end_line integer +---@field del_lines string[] +---@field add_lines string[] + +---@param bufnr integer +---@return diffs.MergeHunkInfo[] +function M.parse_hunks(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local hunks = {} + local current = nil + + for i, line in ipairs(lines) do + local idx = i - 1 + if line:match('^@@') then + if current then + current.end_line = idx - 1 + table.insert(hunks, current) + end + current = { + index = #hunks + 1, + start_line = idx, + end_line = idx, + del_lines = {}, + add_lines = {}, + } + elseif current then + local prefix = line:sub(1, 1) + if prefix == '-' then + table.insert(current.del_lines, line:sub(2)) + elseif prefix == '+' then + table.insert(current.add_lines, line:sub(2)) + elseif prefix ~= ' ' and prefix ~= '\\' then + current.end_line = idx - 1 + table.insert(hunks, current) + current = nil + end + if current then + current.end_line = idx + end + end + end + + if current then + table.insert(hunks, current) + end + + return hunks +end + +---@param bufnr integer +---@return diffs.MergeHunkInfo? +function M.find_hunk_at_cursor(bufnr) + local hunks = M.parse_hunks(bufnr) + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + for _, hunk in ipairs(hunks) do + if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then + return hunk + end + end + + return nil +end + +---@param hunk diffs.MergeHunkInfo +---@param working_bufnr integer +---@return diffs.ConflictRegion? +function M.match_hunk_to_conflict(hunk, working_bufnr) + local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + local regions = conflict.parse(working_lines) + + for _, region in ipairs(regions) do + local ours_lines = {} + for line = region.ours_start + 1, region.ours_end do + table.insert(ours_lines, working_lines[line]) + end + + if #ours_lines == #hunk.del_lines then + local match = true + for j = 1, #ours_lines do + if ours_lines[j] ~= hunk.del_lines[j] then + match = false + break + end + end + if match then + return region + end + end + end + + return nil +end + +---@param diff_bufnr integer +---@return integer? +function M.get_or_load_working_buf(diff_bufnr) + local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path') + if not ok or not working_path then + return nil + end + + local existing = vim.fn.bufnr(working_path) + if existing ~= -1 then + return existing + end + + local bufnr = vim.fn.bufadd(working_path) + vim.fn.bufload(bufnr) + return bufnr +end + +---@param diff_bufnr integer +---@param hunk_index integer +local function mark_resolved(diff_bufnr, hunk_index) + if not resolved_hunks[diff_bufnr] then + resolved_hunks[diff_bufnr] = {} + end + resolved_hunks[diff_bufnr][hunk_index] = true +end + +---@param diff_bufnr integer +---@param hunk_index integer +---@return boolean +function M.is_resolved(diff_bufnr, hunk_index) + return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false +end + +---@param diff_bufnr integer +---@param hunk diffs.MergeHunkInfo +local function add_resolved_virtual_text(diff_bufnr, hunk) + pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, { + virt_text = { { ' (resolved)', 'Comment' } }, + virt_text_pos = 'eol', + }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_ours(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_theirs(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_both(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + local theirs = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + local combined = {} + for _, l in ipairs(ours) do + table.insert(combined, l) + end + for _, l in ipairs(theirs) do + table.insert(combined, l) + end + conflict.replace_region(working_bufnr, region, combined) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_none(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + conflict.replace_region(working_bufnr, region, {}) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +function M.goto_next(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for _, hunk in ipairs(candidates) do + if hunk.start_line > cursor_line then + vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 }) + return + end + end + + vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) +end + +---@param bufnr integer +function M.goto_prev(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for i = #candidates, 1, -1 do + if candidates[i].start_line < cursor_line then + vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 }) + return + end + end + + vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.setup_keymaps(bufnr, config) + local km = config.keymaps + + local maps = { + { km.ours, '(diffs-merge-ours)' }, + { km.theirs, '(diffs-merge-theirs)' }, + { km.both, '(diffs-merge-both)' }, + { km.none, '(diffs-merge-none)' }, + { km.next, '(diffs-merge-next)' }, + { km.prev, '(diffs-merge-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end + end + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + resolved_hunks[bufnr] = nil + end, + }) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 5d3c8b2..face556 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -82,3 +82,28 @@ end, { desc = 'Jump to next conflict' }) vim.keymap.set('n', '(diffs-conflict-prev)', function() require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) end, { desc = 'Jump to previous conflict' }) + +local function merge_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-merge-ours)', function() + merge_action(require('diffs.merge').resolve_ours) +end, { desc = 'Accept ours in merge diff' }) +vim.keymap.set('n', '(diffs-merge-theirs)', function() + merge_action(require('diffs.merge').resolve_theirs) +end, { desc = 'Accept theirs in merge diff' }) +vim.keymap.set('n', '(diffs-merge-both)', function() + merge_action(require('diffs.merge').resolve_both) +end, { desc = 'Accept both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-none)', function() + merge_action(require('diffs.merge').resolve_none) +end, { desc = 'Reject both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-next)', function() + require('diffs.merge').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict hunk' }) +vim.keymap.set('n', '(diffs-merge-prev)', function() + require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict hunk' }) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index daba5d5..1bf9e61 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -40,6 +40,78 @@ describe('commands', function() end) end) + describe('filter_combined_diffs', function() + it('strips diff --cc entries entirely', function() + local lines = { + 'diff --cc main.lua', + 'index d13ab94,b113aee..0000000', + '--- a/main.lua', + '+++ b/main.lua', + '@@@ -1,7 -1,7 +1,11 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '++=======', + '+ return 2', + '++>>>>>>> theirs', + ' end', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + + it('preserves diff --git entries', function() + local lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(8, #result) + assert.are.same(lines, result) + end) + + it('strips combined but keeps unified in mixed output', function() + local lines = { + 'diff --cc conflict.lua', + 'index aaa,bbb..000', + '@@@ -1,1 -1,1 +1,5 @@@', + '++<<<<<<< HEAD', + 'diff --git a/clean.lua b/clean.lua', + '--- a/clean.lua', + '+++ b/clean.lua', + '@@ -1,1 +1,1 @@', + '-old', + '+new', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(6, #result) + assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1]) + assert.are.equal('+new', result[6]) + end) + + it('returns empty for empty input', function() + local result = commands.filter_combined_diffs({}) + assert.are.equal(0, #result) + end) + + it('returns empty when all entries are combined', function() + local lines = { + 'diff --cc a.lua', + 'some content', + 'diff --cc b.lua', + 'more content', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + end) + describe('find_hunk_line', function() it('finds matching @@ header and returns target line', function() local diff_lines = { diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua new file mode 100644 index 0000000..2f788a6 --- /dev/null +++ b/spec/merge_spec.lua @@ -0,0 +1,664 @@ +local helpers = require('spec.helpers') +local merge = require('diffs.merge') + +local function default_config(overrides) + local cfg = { + enabled = true, + disable_diagnostics = false, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + } + if overrides then + cfg = vim.tbl_deep_extend('force', cfg, overrides) + end + return cfg +end + +local function create_diff_buffer(lines, working_path) + local bufnr = helpers.create_buffer(lines) + if working_path then + vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path) + end + return bufnr +end + +local function create_working_buffer(lines, name) + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + if name then + vim.api.nvim_buf_set_name(bufnr, name) + end + return bufnr +end + +describe('merge', function() + describe('parse_hunks', function() + it('parses a single hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(7, hunks[1].end_line) + assert.are.same({ 'local x = 1' }, hunks[1].del_lines) + assert.are.same({ 'local x = 2' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses multiple hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + '@@ -10,3 +10,3 @@', + ' function M.foo()', + '- return 1', + '+ return 2', + ' end', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(2, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(8, hunks[2].start_line) + + helpers.delete_buffer(bufnr) + end) + + it('parses add-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({}, hunks[1].del_lines) + assert.are.same({ 'local new = true' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses delete-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,2 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({ 'local old = false' }, hunks[1].del_lines) + assert.are.same({}, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('returns empty for buffer with no hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(0, #hunks) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('match_hunk_to_conflict', function() + it('matches hunk to conflict region', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_match.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(0, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('returns nil for auto-merged content', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_auto.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local y = 3' }, + add_lines = { 'local y = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with empty ours section', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_empty_ours.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 5, + del_lines = {}, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches correct region among multiple conflicts', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, '/tmp/diffs_test_multi.lua') + + local hunk = { + index = 2, + start_line = 8, + end_line = 12, + del_lines = { 'local b = 3' }, + add_lines = { 'local b = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(6, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with diff3 format', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_diff3.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(2, region.marker_base) + + helpers.delete_buffer(working_bufnr) + end) + end) + + describe('resolution', function() + local diff_bufnr, working_bufnr + + local function setup_buffers() + local working_path = '/tmp/diffs_test_resolve.lua' + working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + diff_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(diff_bufnr) + end + + local function cleanup() + helpers.delete_buffer(diff_bufnr) + helpers.delete_buffer(working_bufnr) + end + + it('resolve_ours keeps ours content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 1', lines[1]) + + cleanup() + end) + + it('resolve_theirs keeps theirs content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_theirs(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + cleanup() + end) + + it('resolve_both keeps ours then theirs in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_both(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(2, #lines) + assert.are.equal('local x = 1', lines[1]) + assert.are.equal('local x = 2', lines[2]) + + cleanup() + end) + + it('resolve_none removes entire block from working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_none(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + cleanup() + end) + + it('tracks resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + assert.is_false(merge.is_resolved(diff_bufnr, 1)) + merge.resolve_ours(diff_bufnr, default_config()) + assert.is_true(merge.is_resolved(diff_bufnr, 1)) + + cleanup() + end) + + it('adds virtual text for resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local extmarks = + vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local has_resolved_text = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + for _, chunk in ipairs(mark[4].virt_text) do + if chunk[1]:match('resolved') then + has_resolved_text = true + end + end + end + end + assert.is_true(has_resolved_text) + + cleanup() + end) + + it('notifies when hunk is already resolved', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('already resolved') then + notified = true + end + end + + merge.resolve_ours(diff_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + cleanup() + end) + + it('notifies when hunk does not match a conflict', function() + local working_path = '/tmp/diffs_test_no_conflict.lua' + local w_bufnr = create_working_buffer({ + 'local y = 1', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('does not correspond') then + notified = true + end + end + + merge.resolve_ours(d_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('navigation', function() + it('goto_next jumps to next conflict hunk', function() + local working_path = '/tmp/diffs_test_nav.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_next wraps around', function() + local working_path = '/tmp/diffs_test_wrap.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev jumps to previous conflict hunk', function() + local working_path = '/tmp/diffs_test_prev.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 9, 0 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev wraps around', function() + local working_path = '/tmp/diffs_test_prev_wrap.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('skips resolved hunks', function() + local working_path = '/tmp/diffs_test_skip_resolved.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(d_bufnr, default_config()) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('fugitive integration', function() + it('parse_file_line returns status for unmerged files', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + }) + local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2) + assert.are.equal('conflict.lua', filename) + assert.are.equal('unstaged', section) + assert.is_false(is_header) + assert.is_nil(old_filename) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parse_file_line returns status for modified files', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'M file.lua', + }) + local _, _, _, _, status = fugitive.get_file_at_line(buf, 2) + assert.are.equal('M', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('walkback from hunk line propagates status', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + }) + local _, _, _, _, status = fugitive.get_file_at_line(buf, 5) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) +end) From f5a090baaea8aca3310b9f1081e7077ae14c94e7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:39:13 -0500 Subject: [PATCH 03/55] perf: cache repo root and harden async paths (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `get_repo_root()` shells out to `git rev-parse` on every call, causing 4-6 redundant subprocesses per `gdiff_file()` invocation. Three other minor issues: `highlight_vim_syntax()` leaks a scratch buffer if `nvim_buf_call` errors, `lib.ensure()` silently drops callbacks during download so hunks highlighted mid-download permanently miss intra-line highlights, and the debounce timer callback can operate on a deleted buffer. ## Solution Cache `get_repo_root()` results by parent directory — repo roots don't change within a session. Wrap `nvim_buf_call` and `nvim_buf_delete` in pcall so the scratch buffer is always cleaned up. Replace the early `callback(nil)` in `lib.ensure()` with a pending callback queue that fires once the download completes. Guard the debounce timer callback with `nvim_buf_is_valid`. --- lua/diffs/git.lua | 6 ++++++ lua/diffs/highlight.lua | 4 ++-- lua/diffs/init.lua | 4 +++- lua/diffs/lib.lua | 30 +++++++++++++++++++----------- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index 7695283..1aa6328 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -1,13 +1,19 @@ local M = {} +local repo_root_cache = {} + ---@param filepath string ---@return string? function M.get_repo_root(filepath) local dir = vim.fn.fnamemodify(filepath, ':h') + if repo_root_cache[dir] ~= nil then + return repo_root_cache[dir] + end local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) if vim.v.shell_error ~= 0 then return nil end + repo_root_cache[dir] = result[1] return result[1] end diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 306b0e5..6cdfe92 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -238,7 +238,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, local spans = {} - vim.api.nvim_buf_call(scratch, function() + pcall(vim.api.nvim_buf_call, scratch, function() vim.cmd('setlocal syntax=' .. ft) vim.cmd('redraw') @@ -256,7 +256,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, spans = M.coalesce_syntax_spans(query_fn, code_lines) end) - vim.api.nvim_buf_delete(scratch, { force = true }) + pcall(vim.api.nvim_buf_delete, scratch, { force = true }) local hunk_line_count = #hunk.lines local extmark_count = 0 diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index cdc29d1..69a5cf9 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -200,7 +200,9 @@ local function create_debounced_highlight(bufnr) timer = nil t:close() end - highlight_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + highlight_buffer(bufnr) + end end) ) end diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua index 5b3254b..8376925 100644 --- a/lua/diffs/lib.lua +++ b/lua/diffs/lib.lua @@ -8,6 +8,9 @@ local cached_handle = nil ---@type boolean local download_in_progress = false +---@type fun(handle: table?)[] +local pending_callbacks = {} + ---@return string local function get_os() local os_name = jit.os:lower() @@ -164,9 +167,10 @@ function M.ensure(callback) return end + table.insert(pending_callbacks, callback) + if download_in_progress then - dbg('download already in progress') - callback(nil) + dbg('download already in progress, queued callback') return end @@ -192,21 +196,25 @@ function M.ensure(callback) vim.system(cmd, {}, function(result) download_in_progress = false vim.schedule(function() + local handle = nil if result.code ~= 0 then vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) dbg('curl failed: %s', result.stderr or '') - callback(nil) - return + else + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() + end + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + handle = M.load() end - local f = io.open(version_path(), 'w') - if f then - f:write(EXPECTED_VERSION) - f:close() + local cbs = pending_callbacks + pending_callbacks = {} + for _, cb in ipairs(cbs) do + cb(handle) end - - vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) - callback(M.load()) end) end) end From b5d28e9f2b6764c37361fd52d8be65d032c5d075 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:55:13 -0500 Subject: [PATCH 04/55] feat(conflict): add virtual text formatting and action lines (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Conflict resolution virtual text only showed plain "current" / "incoming" labels with no keymap hints. Users had no way to discover available resolution keymaps without reading docs. ## Solution Default virtual text labels now include keymap hints: `(current — doo)` and `(incoming — dot)`. A new `format_virtual_text` config option lets users customize or hide labels entirely. A new `show_actions` option (off by default) renders a codelens-style action line above each `<<<<<<<` marker listing all enabled resolution keymaps. Merge diff views also gain hunk hints on `@@` header lines showing available keymaps. New config fields: `conflict.format_virtual_text` (function|nil), `conflict.show_actions` (boolean). New highlight group: `DiffsConflictActions`. --- README.md | 18 ++--- doc/diffs.nvim.txt | 35 +++++++++- lua/diffs/conflict.lua | 57 ++++++++++++--- lua/diffs/init.lua | 6 ++ lua/diffs/merge.lua | 39 +++++++++++ spec/conflict_spec.lua | 155 +++++++++++++++++++++++++++++++++++++++++ spec/merge_spec.lua | 60 ++++++++++++++++ 7 files changed, 347 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f0a04a8..996ee27 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,12 @@ syntax highlighting. ## Features -- Treesitter syntax highlighting in `:Git` diffs and commit views -- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) -- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds -- `:Gdiff` unified diff against any git revision with syntax highlighting -- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs -- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) -- Vim syntax fallback for languages without a treesitter parser -- Hunk header context highlighting (`@@ ... @@ function foo()`) -- Character-level (intra-line) diff highlighting for changed characters -- Inline merge conflict detection, highlighting, and resolution keymaps -- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and - highlight overrides +- Treesitter syntax highlighting in fugitive diffs and commit views +- Character-level intra-line diff highlighting +- `:Gdiff` unified diff against any revision +- Background-only diff colors for `&diff` buffers +- Inline merge conflict detection, highlighting, and resolution +- Vim syntax fallback, context padding, configurable blend/debounce ## Requirements diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6bd848c..f199d31 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -85,6 +85,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -416,6 +417,7 @@ Configuration: ~ enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -442,9 +444,32 @@ Configuration: ~ diagnostics alone. {show_virtual_text} (boolean, default: true) - Show virtual text labels (" current" and - " incoming") at the end of `<<<<<<<` and - `>>>>>>>` marker lines. + Show `(current)` and `(incoming)` labels at + the end of `<<<<<<<` and `>>>>>>>` marker + lines. Also controls hunk hints in merge + diff views. + + {format_virtual_text} (function|nil, default: nil) + Custom formatter for virtual text labels. + Receives `(side, keymap)` where `side` is + `"ours"` or `"theirs"` and `keymap` is the + configured keymap string or `false`. Return + a string (label text without parens) or + `nil` to hide the label. Example: >lua + format_virtual_text = function(side, keymap) + if keymap then + return side .. ' [' .. keymap .. ']' + end + return side + end +< + + {show_actions} (boolean, default: false) + Show a codelens-style action line above each + `<<<<<<<` marker listing available resolution + keymaps. Renders as virtual lines using the + `DiffsConflictActions` highlight group. + Only keymaps that are not `false` appear. {keymaps} (table, default: see above) Buffer-local keymaps for conflict resolution @@ -687,6 +712,10 @@ Conflict highlights: ~ *DiffsConflictBaseNr* DiffsConflictBaseNr Line number for base content lines (diff3). + *DiffsConflictActions* + DiffsConflictActions Dimmed foreground (no bold) for the codelens-style + action line shown when `show_actions` is true. + Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 894c0b8..ce3618d 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -92,6 +92,17 @@ local function parse_buffer(bufnr) return M.parse(lines) end +---@param side string +---@param config diffs.ConflictConfig +---@return string? +local function get_virtual_text_label(side, config) + if config.format_virtual_text then + local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs + return config.format_virtual_text(side, keymap) + end + return side == 'ours' and 'current' or 'incoming' +end + ---@param bufnr integer ---@param regions diffs.ConflictRegion[] ---@param config diffs.ConflictConfig @@ -107,10 +118,37 @@ local function apply_highlights(bufnr, regions, config) }) if config.show_virtual_text then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' (current)', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) + local ours_label = get_virtual_text_label('ours', config) + if ours_label then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end + end + + if config.show_actions then + local parts = {} + local actions = { + { 'Current', config.keymaps.ours }, + { 'Incoming', config.keymaps.theirs }, + { 'Both', config.keymaps.both }, + { 'None', config.keymaps.none }, + } + for _, action in ipairs(actions) do + if action[2] then + if #parts > 0 then + table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' }) + end + table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' }) + end + end + if #parts > 0 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_lines = { parts }, + virt_lines_above = true, + }) + end end for line = region.ours_start, region.ours_end - 1 do @@ -176,10 +214,13 @@ local function apply_highlights(bufnr, regions, config) }) if config.show_virtual_text then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) + local theirs_label = get_virtual_text_label('theirs', config) + if theirs_label then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end end end end diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 69a5cf9..85dc879 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -41,6 +41,8 @@ ---@field enabled boolean ---@field disable_diagnostics boolean ---@field show_virtual_text boolean +---@field format_virtual_text? fun(side: string, keymap: string|false): string? +---@field show_actions boolean ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config @@ -128,6 +130,7 @@ local default_config = { enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -276,6 +279,7 @@ local function compute_highlight_groups() vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs }) vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true }) + vim.api.nvim_set_hl(0, 'DiffsConflictActions', { default = true, fg = 0x808080 }) vim.api.nvim_set_hl( 0, 'DiffsConflictOursNr', @@ -390,6 +394,8 @@ local function init() ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, + ['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true }, + ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true }, ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, }) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua index 9bb9d68..4332220 100644 --- a/lua/diffs/merge.lua +++ b/lua/diffs/merge.lua @@ -338,6 +338,43 @@ function M.goto_prev(bufnr) vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) end +---@param bufnr integer +---@param config diffs.ConflictConfig +local function apply_hunk_hints(bufnr, config) + if not config.show_virtual_text then + return + end + + local hunks = M.parse_hunks(bufnr) + for _, hunk in ipairs(hunks) do + if M.is_resolved(bufnr, hunk.index) then + add_resolved_virtual_text(bufnr, hunk) + else + local parts = {} + local actions = { + { 'current', config.keymaps.ours }, + { 'incoming', config.keymaps.theirs }, + { 'both', config.keymaps.both }, + { 'none', config.keymaps.none }, + } + for _, action in ipairs(actions) do + if action[2] then + if #parts > 0 then + table.insert(parts, { ' | ', 'Comment' }) + end + table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' }) + end + end + if #parts > 0 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, { + virt_text = parts, + virt_text_pos = 'eol', + }) + end + end + end +end + ---@param bufnr integer ---@param config diffs.ConflictConfig function M.setup_keymaps(bufnr, config) @@ -358,6 +395,8 @@ function M.setup_keymaps(bufnr, config) end end + apply_hunk_hints(bufnr, config) + vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, callback = function() diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 75eac23..b163960 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -6,6 +6,7 @@ local function default_config(overrides) enabled = true, disable_diagnostics = false, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -685,4 +686,158 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) end) + + describe('virtual text formatting', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('default labels show current and incoming without keymaps', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local labels = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + table.insert(labels, mark[4].virt_text[1][1]) + end + end + assert.are.equal(2, #labels) + assert.are.equal(' (current)', labels[1]) + assert.are.equal(' (incoming)', labels[2]) + + helpers.delete_buffer(bufnr) + end) + + it('uses custom format_virtual_text function', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ + format_virtual_text = function(side) + return side == 'ours' and 'OURS' or 'THEIRS' + end, + }) + ) + + local extmarks = get_extmarks(bufnr) + local labels = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + table.insert(labels, mark[4].virt_text[1][1]) + end + end + assert.are.equal(2, #labels) + assert.are.equal(' (OURS)', labels[1]) + assert.are.equal(' (THEIRS)', labels[2]) + + helpers.delete_buffer(bufnr) + end) + + it('hides label when format_virtual_text returns nil', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ + format_virtual_text = function() + return nil + end, + }) + ) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('action lines', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('adds virt_lines when show_actions is true', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_actions = true })) + + local extmarks = get_extmarks(bufnr) + local virt_lines_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_lines then + virt_lines_count = virt_lines_count + 1 + end + end + assert.are.equal(1, virt_lines_count) + + helpers.delete_buffer(bufnr) + end) + + it('omits disabled keymaps from action line', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ show_actions = true, keymaps = { both = false, none = false } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_lines then + local line = mark[4].virt_lines[1] + local text = '' + for _, chunk in ipairs(line) do + text = text .. chunk[1] + end + assert.is_truthy(text:find('Current')) + assert.is_truthy(text:find('Incoming')) + assert.is_falsy(text:find('Both')) + assert.is_falsy(text:find('None')) + end + end + + helpers.delete_buffer(bufnr) + end) + end) end) diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 2f788a6..7d5019b 100644 --- a/spec/merge_spec.lua +++ b/spec/merge_spec.lua @@ -6,6 +6,7 @@ local function default_config(overrides) enabled = true, disable_diagnostics = false, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -617,6 +618,65 @@ describe('merge', function() end) end) + describe('hunk hints', function() + it('adds keymap hints on hunk header lines', function() + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }) + + merge.setup_keymaps(d_bufnr, default_config()) + + local extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local hint_marks = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + local text = '' + for _, chunk in ipairs(mark[4].virt_text) do + text = text .. chunk[1] + end + table.insert(hint_marks, { line = mark[2], text = text }) + end + end + assert.are.equal(1, #hint_marks) + assert.are.equal(3, hint_marks[1].line) + assert.is_truthy(hint_marks[1].text:find('doo')) + assert.is_truthy(hint_marks[1].text:find('dot')) + + helpers.delete_buffer(d_bufnr) + end) + + it('does not add hints when show_virtual_text is false', function() + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }) + + merge.setup_keymaps(d_bufnr, default_config({ show_virtual_text = false })) + + local extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(d_bufnr) + end) + end) + describe('fugitive integration', function() it('parse_file_line returns status for unmerged files', function() local fugitive = require('diffs.fugitive') From 35067151e40b7b9a589ebe98c264d086df1fc36e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:08:36 -0500 Subject: [PATCH 05/55] fix: pre-release cleanup for v0.2.0 (#102) ## Problem Three minor issues remain before the v0.2.0 release: 1. Git quotes filenames containing spaces, unicode, or special characters in the fugitive status buffer. `parse_file_line` passed the quotes through verbatim, causing file-not-found errors on diff operations. 2. Navigation wrap-around in both conflict and merge modules was silent, giving no indication when jumping past the last/first item back to the beginning/end. 3. `resolved_hunks` and `(resolved)` virtual text in the merge module persisted across buffer re-reads, showing stale markers for hunks that were no longer resolved. ## Solution 1. Add an `unquote()` helper to fugitive.lua that strips surrounding quotes and unescapes `\\`, `\"`, `\n`, `\t`, and octal `\NNN` sequences. Applied to both return paths in `parse_file_line`. 2. Add `vim.notify` before the wrap-around jump in all four navigation functions (`goto_next`/`goto_prev` in conflict.lua and merge.lua). 3. Clear `resolved_hunks[bufnr]` and the merge namespace at the top of `setup_keymaps` so each buffer init starts fresh. Closes #66 --- doc/diffs.nvim.txt | 58 +++- lua/diffs/conflict.lua | 24 +- lua/diffs/fugitive.lua | 49 +++- lua/diffs/highlight.lua | 110 +++----- lua/diffs/init.lua | 37 +++ lua/diffs/merge.lua | 5 + spec/conflict_spec.lua | 77 ++++-- spec/fugitive_spec.lua | 195 +++----------- spec/highlight_spec.lua | 582 +--------------------------------------- spec/merge_spec.lua | 127 +++++++-- spec/parser_spec.lua | 60 ----- 11 files changed, 393 insertions(+), 931 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index f199d31..d1df476 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -75,6 +75,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, overrides = {}, }, fugitive = { @@ -161,6 +167,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. + {priorities} (table, default: see below) + Extmark priority values. + See |diffs.PrioritiesConfig| for fields. + {overrides} (table, default: {}) Map of highlight group names to highlight definitions (see |nvim_set_hl()|). Applied @@ -182,6 +192,28 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: direction. Lines are read with early exit — cost scales with this value, not file size. + *diffs.PrioritiesConfig* + Priorities config fields: ~ + {clear} (integer, default: 198) + Priority for `DiffsClear` extmarks that reset + underlying diff foreground colors. Must be + below {syntax}. + + {syntax} (integer, default: 199) + Priority for treesitter and vim syntax extmarks. + Must be below {line_bg} so that colorscheme + backgrounds on syntax groups do not obscure + line-level diff backgrounds. + + {line_bg} (integer, default: 200) + Priority for `DiffsAdd`/`DiffsDelete` line + background extmarks. Must be below {char_bg}. + + {char_bg} (integer, default: 201) + Priority for `DiffsAddText`/`DiffsDeleteText` + character-level background extmarks. Highest + priority so changed characters stand out. + *diffs.TreesitterConfig* Treesitter config fields: ~ {enabled} (boolean, default: true) @@ -418,6 +450,7 @@ Configuration: ~ disable_diagnostics = true, show_virtual_text = true, show_actions = false, + priority = 200, keymaps = { ours = 'doo', theirs = 'dot', @@ -471,6 +504,11 @@ Configuration: ~ `DiffsConflictActions` highlight group. Only keymaps that are not `false` appear. + {priority} (integer, default: 200) + Extmark priority for conflict region + backgrounds and markers. Adjust if other + plugins use the same priority range. + {keymaps} (table, default: see above) Buffer-local keymaps for conflict resolution and navigation. Each value accepts a string @@ -566,12 +604,13 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - `Normal` extmarks at priority 198 clear underlying diff foreground - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 - - Syntax highlights are applied as extmarks at priority 200 + - `DiffsClear` extmarks at priority 198 clear underlying diff foreground + - Syntax highlights are applied as extmarks at priority 199 + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200 - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled + - All priorities are configurable via |diffs.PrioritiesConfig| 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events Diff mode views: ~ @@ -586,15 +625,10 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation. To provide surrounding code -context, diffs.nvim reads lines from disk before and after each hunk -(see |diffs.ContextConfig|, enabled by default). This resolves most boundary -issues where incomplete constructs (e.g., a function definition at the edge -of a hunk with no body) would confuse the parser. - -Set `highlights.context.enabled = false` to disable context padding. In rare -cases, context padding may not help if the relevant surrounding code is very -far from the hunk boundaries. +Treesitter parses each diff hunk in isolation. Context lines within the hunk +(lines with a ` ` prefix) provide syntactic context for the parser. In rare +cases, hunks that start or end mid-expression may produce imperfect highlights +due to treesitter error recovery. Syntax Highlighting Flash ~ *diffs-flash* diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index ce3618d..5ed8009 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -20,8 +20,6 @@ local attached_buffers = {} ---@type table local diagnostics_suppressed = {} -local PRIORITY_LINE_BG = 200 - ---@param lines string[] ---@return diffs.ConflictRegion[] function M.parse(lines) @@ -114,7 +112,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_ours + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) if config.show_virtual_text then @@ -156,11 +154,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictOurs', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictOursNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end @@ -169,7 +167,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_base + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) for line = region.base_start, region.base_end - 1 do @@ -177,11 +175,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictBase', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictBaseNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end end @@ -190,7 +188,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_sep + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) for line = region.theirs_start, region.theirs_end - 1 do @@ -198,11 +196,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictTheirs', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictTheirsNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end @@ -210,7 +208,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_theirs + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) if config.show_virtual_text then @@ -364,6 +362,7 @@ function M.goto_next(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) end @@ -381,6 +380,7 @@ function M.goto_prev(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) end diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index b11985c..d4c3782 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,17 +26,62 @@ function M.get_section_at_line(bufnr, lnum) return nil end +---@param s string +---@return string +local function unquote(s) + if s:sub(1, 1) ~= '"' then + return s + end + local inner = s:sub(2, -2) + local result = {} + local i = 1 + while i <= #inner do + if inner:sub(i, i) == '\\' and i < #inner then + local next_char = inner:sub(i + 1, i + 1) + if next_char == 'n' then + table.insert(result, '\n') + i = i + 2 + elseif next_char == 't' then + table.insert(result, '\t') + i = i + 2 + elseif next_char == '"' then + table.insert(result, '"') + i = i + 2 + elseif next_char == '\\' then + table.insert(result, '\\') + i = i + 2 + elseif next_char:match('%d') then + local oct = inner:match('^(%d%d%d)', i + 1) + if oct then + table.insert(result, string.char(tonumber(oct, 8))) + i = i + 4 + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, inner:sub(i, i)) + i = i + 1 + end + end + return table.concat(result) +end + ---@param line string ---@return string?, string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return vim.trim(new), vim.trim(old), 'R' + return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' end local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') if status and filename then - return vim.trim(filename), nil, status + return unquote(vim.trim(filename)), nil, status end return nil, nil, nil diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 6cdfe92..d2f65e9 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,38 +3,6 @@ local M = {} local dbg = require('diffs.log').dbg local diff = require('diffs.diff') ----@param filepath string ----@param from_line integer ----@param count integer ----@return string[] -local function read_line_range(filepath, from_line, count) - if count <= 0 then - return {} - end - local f = io.open(filepath, 'r') - if not f then - return {} - end - local result = {} - local line_num = 0 - for line in f:lines() do - line_num = line_num + 1 - if line_num >= from_line then - table.insert(result, line) - if #result >= count then - break - end - end - end - f:close() - return result -end - -local PRIORITY_CLEAR = 198 -local PRIORITY_SYNTAX = 199 -local PRIORITY_LINE_BG = 200 -local PRIORITY_CHAR_BG = 201 - ---@param bufnr integer ---@param ns integer ---@param hunk diffs.Hunk @@ -42,8 +10,9 @@ local PRIORITY_CHAR_BG = 201 ---@param text string ---@param lang string ---@param context_lines? string[] +---@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines) +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities) local parse_text = text if context_lines and #context_lines > 0 then parse_text = text .. '\n' .. table.concat(context_lines, '\n') @@ -77,7 +46,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l local buf_sc = col_offset + sc local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -103,6 +72,7 @@ end ---@param line_map table ---@param col_offset integer ---@param covered_lines? table +---@param priorities diffs.PrioritiesConfig ---@return integer local function highlight_treesitter( bufnr, @@ -111,7 +81,8 @@ local function highlight_treesitter( lang, line_map, col_offset, - covered_lines + covered_lines, + priorities ) local code = table.concat(code_lines, '\n') if code == '' then @@ -152,7 +123,7 @@ local function highlight_treesitter( local buf_ec = ec + col_offset local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) - or PRIORITY_SYNTAX + or priorities.syntax pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -219,8 +190,17 @@ end ---@param code_lines string[] ---@param covered_lines? table ---@param leading_offset? integer +---@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) +local function highlight_vim_syntax( + bufnr, + ns, + hunk, + code_lines, + covered_lines, + leading_offset, + priorities +) local ft = hunk.ft if not ft then return 0 @@ -267,7 +247,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { end_col = span.col_end, hl_group = span.hl_name, - priority = PRIORITY_SYNTAX, + priority = priorities.syntax, }) extmark_count = extmark_count + 1 if covered_lines then @@ -284,6 +264,7 @@ end ---@param hunk diffs.Hunk ---@param opts diffs.HunkOpts function M.highlight_hunk(bufnr, ns, hunk, opts) + local p = opts.highlights.priorities local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled @@ -303,21 +284,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local covered_lines = {} - local ctx_cfg = opts.highlights.context - local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0 - local leading = {} - local trailing = {} - if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then - local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename) - local lead_from = math.max(1, hunk.file_new_start - context) - local lead_count = hunk.file_new_start - lead_from - if lead_count > 0 then - leading = read_line_range(filepath, lead_from, lead_count) - end - local trail_from = hunk.file_new_start + (hunk.file_new_count or 0) - trailing = read_line_range(filepath, trail_from, context) - end - local extmark_count = 0 if use_ts then ---@type string[] @@ -329,11 +295,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local old_map = {} - for _, pad_line in ipairs(leading) do - table.insert(new_code, pad_line) - table.insert(old_code, pad_line) - end - for i, line in ipairs(hunk.lines) do local prefix = line:sub(1, 1) local stripped = line:sub(2) @@ -352,21 +313,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - for _, pad_line in ipairs(trailing) do - table.insert(new_code, pad_line) - table.insert(old_code, pad_line) - end - - extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines) + extmark_count = + highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines, p) extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines) + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines, p) if hunk.header_context and hunk.header_context_col then local header_line = hunk.start_line - 1 pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { end_col = hunk.header_context_col + #hunk.header_context, hl_group = 'DiffsClear', - priority = PRIORITY_CLEAR, + priority = p.clear, }) local header_extmarks = highlight_text( bufnr, @@ -375,7 +332,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hunk.header_context_col, hunk.header_context, hunk.lang, - new_code + new_code, + p ) if header_extmarks > 0 then dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) @@ -385,16 +343,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then ---@type string[] local code_lines = {} - for _, pad_line in ipairs(leading) do - table.insert(code_lines, pad_line) - end for _, line in ipairs(hunk.lines) do table.insert(code_lines, line:sub(2)) end - for _, pad_line in ipairs(trailing) do - table.insert(code_lines, pad_line) - end - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading) + extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) end if @@ -409,7 +361,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p) end ---@type diffs.IntraChanges? @@ -467,7 +419,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { end_col = line_len, hl_group = 'DiffsClear', - priority = PRIORITY_CLEAR, + priority = p.clear, }) end @@ -476,12 +428,12 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end_row = buf_line + 1, hl_group = line_hl, hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = p.line_bg, }) if opts.highlights.gutter then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { number_hl_group = number_hl, - priority = PRIORITY_LINE_BG, + priority = p.line_bg, }) end end @@ -501,7 +453,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { end_col = span.col_end, hl_group = char_hl, - priority = PRIORITY_CHAR_BG, + priority = p.char_bg, }) if not ok then dbg('char extmark FAILED: %s', err) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 85dc879..00dddca 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -15,6 +15,12 @@ ---@field enabled boolean ---@field lines integer +---@class diffs.PrioritiesConfig +---@field clear integer +---@field syntax integer +---@field line_bg integer +---@field char_bg integer + ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean @@ -24,6 +30,7 @@ ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig +---@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false @@ -43,6 +50,7 @@ ---@field show_virtual_text boolean ---@field format_virtual_text? fun(side: string, keymap: string|false): string? ---@field show_actions boolean +---@field priority integer ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config @@ -121,6 +129,12 @@ local default_config = { algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, }, fugitive = { horizontal = 'du', @@ -131,6 +145,7 @@ local default_config = { disable_diagnostics = true, show_virtual_text = true, show_actions = false, + priority = 200, keymaps = { ours = 'doo', theirs = 'dot', @@ -328,6 +343,7 @@ local function init() ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, ['highlights.intra'] = { opts.highlights.intra, 'table', true }, + ['highlights.priorities'] = { opts.highlights.priorities, 'table', true }, }) if opts.highlights.context then @@ -368,6 +384,15 @@ local function init() ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, }) end + + if opts.highlights.priorities then + vim.validate({ + ['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true }, + ['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true }, + ['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true }, + ['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true }, + }) + end end if opts.fugitive then @@ -396,6 +421,7 @@ local function init() ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, ['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true }, ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true }, + ['conflict.priority'] = { opts.conflict.priority, 'number', true }, ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, }) @@ -457,6 +483,17 @@ local function init() then error('diffs: highlights.blend_alpha must be >= 0 and <= 1') end + if opts.highlights and opts.highlights.priorities then + for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do + local v = opts.highlights.priorities[key] + if v and v < 0 then + error('diffs: highlights.priorities.' .. key .. ' must be >= 0') + end + end + end + if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then + error('diffs: conflict.priority must be >= 0') + end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua index 4332220..daf72ac 100644 --- a/lua/diffs/merge.lua +++ b/lua/diffs/merge.lua @@ -298,6 +298,7 @@ function M.goto_next(bufnr) end end + vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) end @@ -335,6 +336,7 @@ function M.goto_prev(bufnr) end end + vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) end @@ -378,6 +380,9 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig function M.setup_keymaps(bufnr, config) + resolved_hunks[bufnr] = nil + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local km = config.keymaps local maps = { diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index b163960..dd190c9 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -235,29 +235,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('does not apply virtual text when disabled', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config({ show_virtual_text = false })) - - local extmarks = get_extmarks(bufnr) - local virt_text_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - virt_text_count = virt_text_count + 1 - end - end - assert.are.equal(0, virt_text_count) - - helpers.delete_buffer(bufnr) - end) - it('applies number_hl_group to content lines', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -532,6 +509,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_next notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first conflict') then + notified = true + end + end + + conflict.goto_next(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_prev jumps to previous conflict', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -576,6 +580,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_prev notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last conflict') then + notified = true + end + end + + conflict.goto_prev(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_next does nothing with no conflicts', function() local bufnr = create_file_buffer({ 'normal line' }) vim.api.nvim_set_current_buf(bufnr) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 244e1b7..95b40a3 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -87,28 +87,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('parses added file', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'A newfile.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('newfile.lua', filename) - assert.equals('staged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('parses deleted file', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'D oldfile.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('oldfile.lua', filename) - assert.equals('staged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', @@ -157,28 +135,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('handles renamed file in subdirectory', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R src/old.lua -> src/new.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('src/new.lua', filename) - assert.equals('src/old.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles renamed file moved to different directory', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R old/file.lua -> new/file.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new/file.lua', filename) - assert.equals('old/file.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() local buf = create_status_buffer({ 'Staged (1)', @@ -190,77 +146,54 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('handles double extensions', function() + it('unquotes git-quoted filenames with spaces', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "path with spaces/file.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('path with spaces/file.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes escaped quotes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "file\\"name.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('file"name.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes octal escapes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "\\303\\251le.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('\195\169le.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('passes through unquoted filenames unchanged', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M normal.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('normal.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes renamed files with quotes', function() local buf = create_status_buffer({ 'Staged (1)', - 'M test.spec.lua', + 'R100 "old name.lua" -> "new name.lua"', }) local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('test.spec.lua', filename) - assert.is_nil(old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles hyphenated filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M my-component-test.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('my-component-test.lua', filename) - assert.equals('unstaged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles underscores and numbers', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'A test_file_123.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('test_file_123.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles dotfiles', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M .gitignore', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('.gitignore', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles renamed with complex names', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R src/old-file.spec.lua -> src/new-file.spec.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('src/new-file.spec.lua', filename) - assert.equals('src/old-file.spec.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles deeply nested paths', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M lua/diffs/ui/components/diff-view.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('lua/diffs/ui/components/diff-view.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('parses untracked file', function() - local buf = create_status_buffer({ - 'Untracked (1)', - '? untracked.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('untracked.lua', filename) - assert.equals('untracked', section) + assert.equals('new name.lua', filename) + assert.equals('old name.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -321,30 +254,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('detects section header for Unstaged', function() - local buf = create_status_buffer({ - 'Unstaged (3)', - 'M file1.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 1) - assert.is_nil(filename) - assert.equals('unstaged', section) - assert.is_true(is_header) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('detects section header for Untracked', function() - local buf = create_status_buffer({ - 'Untracked (1)', - '? newfile.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 1) - assert.is_nil(filename) - assert.equals('untracked', section) - assert.is_true(is_header) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('returns is_header=false for file lines', function() local buf = create_status_buffer({ 'Staged (1)', @@ -406,22 +315,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('returns hunk header and offset for - line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local old = false', - ' return M', - }) - local pos = fugitive.get_hunk_position(buf, 5) - assert.is_not_nil(pos) - assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header) - assert.equals(2, pos.offset) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('returns hunk header and offset for context line', function() local buf = create_status_buffer({ 'Unstaged (1)', diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 0b9051e..4064857 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -51,6 +51,12 @@ describe('highlight', function() algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, }, } if overrides then @@ -64,27 +70,6 @@ describe('highlight', function() return opts end - it('applies extmarks for lua code', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -190,36 +175,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('highlights header context when enabled', function() - local bufnr = create_buffer({ - '@@ -10,3 +10,4 @@ function hello()', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - header_context = 'function hello()', - header_context_col = 18, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_header_extmark = false - for _, mark in ipairs(extmarks) do - if mark[2] == 0 then - has_header_extmark = true - break - end - end - assert.is_true(has_header_extmark) - delete_buffer(bufnr) - end) - it('highlights function keyword in header context', function() local bufnr = create_buffer({ '@@ -5,3 +5,4 @@ function M.setup()', @@ -280,44 +235,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('handles empty hunk lines', function() - local bufnr = create_buffer({ - '@@ -1,0 +1,0 @@', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = {}, - } - - assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - end) - delete_buffer(bufnr) - end) - - it('handles code that is just whitespace', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' ', - '+ ', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' ', '+ ' }, - } - - assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - end) - delete_buffer(bufnr) - end) - it('applies overlay extmarks when hide_prefix enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -345,33 +262,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply overlay extmarks when hide_prefix disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false })) - - local extmarks = get_extmarks(bufnr) - local overlay_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text_pos == 'overlay' then - overlay_count = overlay_count + 1 - end - end - assert.are.equal(0, overlay_count) - delete_buffer(bufnr) - end) - it('applies DiffAdd background to + lines when background enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -438,39 +328,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply background when background disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = false } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_line_hl = false - for _, mark in ipairs(extmarks) do - if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then - has_line_hl = true - break - end - end - assert.is_false(has_line_hl) - delete_buffer(bufnr) - end) - it('applies number_hl_group when gutter enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -504,72 +361,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply number_hl_group when gutter disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, gutter = false } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_number_hl = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].number_hl_group then - has_number_hl = true - break - end - end - assert.is_false(has_number_hl) - delete_buffer(bufnr) - end) - - it('skips treesitter highlights when treesitter disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_ts_highlight = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then - has_ts_highlight = true - break - end - end - assert.is_false(has_ts_highlight) - delete_buffer(bufnr) - end) - it('still applies background when treesitter disabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -654,40 +445,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('skips vim fallback when vim.enabled is false', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - ft = 'abap', - lang = nil, - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { vim = { enabled = false } } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_syntax_hl = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then - has_syntax_hl = true - break - end - end - assert.is_false(has_syntax_hl) - delete_buffer(bufnr) - end) - it('respects vim.max_lines', function() local lines = { '@@ -1,100 +1,101 @@' } local hunk_lines = {} @@ -900,92 +657,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('line bg priority > DiffsClear priority', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,1 @@', - '-local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local clear_priority = nil - local line_bg_priority = nil - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' then - clear_priority = d.priority - end - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - line_bg_priority = d.priority - end - end - assert.is_not_nil(clear_priority) - assert.is_not_nil(line_bg_priority) - assert.is_true(line_bg_priority > clear_priority) - delete_buffer(bufnr) - end) - - it('char-level extmarks have higher priority than line bg', 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 @@', - '-local x = 1', - '+local x = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { - background = true, - intra = { enabled = true, algorithm = 'default', max_lines = 500 }, - }, - }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bg_priority = nil - local char_bg_priority = nil - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - line_bg_priority = d.priority - end - if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then - char_bg_priority = d.priority - end - end - assert.is_not_nil(line_bg_priority) - assert.is_not_nil(char_bg_priority) - assert.is_true(char_bg_priority > line_bg_priority) - delete_buffer(bufnr) - end) - it('creates char-level extmarks for changed characters', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) @@ -1029,38 +700,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not create char-level extmarks when intra disabled', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { intra = { enabled = false, 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('does not create char-level extmarks for pure additions', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) @@ -1157,142 +796,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('context padding produces no extmarks on padding lines', function() - local repo_root = '/tmp/diffs-test-context' - vim.fn.mkdir(repo_root, 'p') - - local f = io.open(repo_root .. '/test.lua', 'w') - f:write('local M = {}\n') - f:write('function M.hello()\n') - f:write(' return "hi"\n') - f:write('end\n') - f:write('return M\n') - f:close() - - local bufnr = create_buffer({ - '@@ -3,1 +3,2 @@', - ' return "hi"', - '+"bye"', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' return "hi"', '+"bye"' }, - file_old_start = 3, - file_old_count = 1, - file_new_start = 3, - file_new_count = 2, - repo_root = repo_root, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local row = mark[2] - assert.is_true(row >= 1 and row <= 2) - end - - delete_buffer(bufnr) - os.remove(repo_root .. '/test.lua') - vim.fn.delete(repo_root, 'rf') - end) - - it('context disabled matches behavior without padding', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - file_new_start = 1, - file_new_count = 2, - repo_root = '/nonexistent', - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = false, lines = 0 } } }) - ) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - - it('gracefully handles missing file for context padding', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - file_new_start = 1, - file_new_count = 2, - repo_root = '/nonexistent/path', - } - - assert.has_no.errors(function() - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) - ) - end) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - - it('highlights treesitter injections', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+vim.cmd([[ echo 1 ]])', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_vim_capture = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then - has_vim_capture = true - break - end - end - assert.is_true(has_vim_capture) - delete_buffer(bufnr) - end) - it('includes captures from both base and injected languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -1386,6 +889,7 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end @@ -1468,47 +972,6 @@ describe('highlight', function() assert.are.equal(0, header_extmarks) delete_buffer(bufnr) end) - - it('does not apply header highlights when treesitter disabled', function() - local bufnr = create_buffer({ - 'diff --git a/parser.lua b/parser.lua', - 'index 3e8afa0..018159c 100644', - '--- a/parser.lua', - '+++ b/parser.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - }) - - local hunk = { - filename = 'parser.lua', - lang = 'lua', - start_line = 5, - lines = { ' local M = {}', '+local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --git a/parser.lua b/parser.lua', - 'index 3e8afa0..018159c 100644', - '--- a/parser.lua', - '+++ b/parser.lua', - }, - } - - local opts = default_opts() - opts.highlights.treesitter.enabled = false - - highlight.highlight_hunk(bufnr, ns, hunk, opts) - - local extmarks = get_extmarks(bufnr) - local header_extmarks = 0 - for _, mark in ipairs(extmarks) do - if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then - header_extmarks = header_extmarks + 1 - end - end - assert.are.equal(0, header_extmarks) - delete_buffer(bufnr) - end) end) describe('extmark priority', function() @@ -1543,40 +1006,11 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end - it('uses priority 199 for code languages', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_priority_199 = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then - if mark[4].priority == 199 then - has_priority_199 = true - break - end - end - end - assert.is_true(has_priority_199) - delete_buffer(bufnr) - end) - it('uses treesitter priority for diff language', function() local bufnr = create_buffer({ 'diff --git a/test.lua b/test.lua', diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 7d5019b..6cb9f7e 100644 --- a/spec/merge_spec.lua +++ b/spec/merge_spec.lua @@ -509,6 +509,44 @@ describe('merge', function() helpers.delete_buffer(w_bufnr) end) + it('goto_next notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first hunk') then + notified = true + end + end + + merge.goto_next(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + it('goto_prev jumps to previous conflict hunk', function() local working_path = '/tmp/diffs_test_prev.lua' local w_bufnr = create_working_buffer({ @@ -577,6 +615,44 @@ describe('merge', function() helpers.delete_buffer(w_bufnr) end) + it('goto_prev notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_prev_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last hunk') then + notified = true + end + end + + merge.goto_prev(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + it('skips resolved hunks', function() local working_path = '/tmp/diffs_test_skip_resolved.lua' local w_bufnr = create_working_buffer({ @@ -650,8 +726,19 @@ describe('merge', function() helpers.delete_buffer(d_bufnr) end) + end) + + describe('setup_keymaps', function() + it('clears resolved state on re-init', function() + local working_path = '/tmp/diffs_test_reinit.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) - it('does not add hints when show_virtual_text is false', function() local d_bufnr = create_diff_buffer({ 'diff --git a/file.lua b/file.lua', '--- a/file.lua', @@ -659,21 +746,37 @@ describe('merge', function() '@@ -1,1 +1,1 @@', '-local x = 1', '+local x = 2', - }) + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) - merge.setup_keymaps(d_bufnr, default_config({ show_virtual_text = false })) + local cfg = default_config() + merge.resolve_ours(d_bufnr, cfg) + assert.is_true(merge.is_resolved(d_bufnr, 1)) local extmarks = vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local virt_text_count = 0 + assert.is_true(#extmarks > 0) + + merge.setup_keymaps(d_bufnr, cfg) + + assert.is_false(merge.is_resolved(d_bufnr, 1)) + extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local resolved_count = 0 for _, mark in ipairs(extmarks) do if mark[4] and mark[4].virt_text then - virt_text_count = virt_text_count + 1 + for _, chunk in ipairs(mark[4].virt_text) do + if chunk[1]:match('resolved') then + resolved_count = resolved_count + 1 + end + end end end - assert.are.equal(0, virt_text_count) + assert.are.equal(0, resolved_count) helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) end) end) @@ -694,18 +797,6 @@ describe('merge', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('parse_file_line returns status for modified files', function() - local fugitive = require('diffs.fugitive') - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'Unstaged (1)', - 'M file.lua', - }) - local _, _, _, _, status = fugitive.get_file_at_line(buf, 2) - assert.are.equal('M', status) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('walkback from hunk line propagates status', function() local fugitive = require('diffs.fugitive') local buf = vim.api.nvim_create_buf(false, true) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 11ac3be..32a3001 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -391,37 +391,6 @@ describe('parser', function() vim.fn.delete(repo_root, 'rf') end) - it('detects python from shebang without open buffer', function() - local repo_root = '/tmp/diffs-test-shebang-py' - vim.fn.mkdir(repo_root, 'p') - - local file_path = repo_root .. '/deploy' - local f = io.open(file_path, 'w') - f:write('#!/usr/bin/env python3\n') - f:write('import sys\n') - f:write('print("hi")\n') - f:close() - - local diff_buf = create_buffer({ - 'M deploy', - '@@ -1,2 +1,3 @@', - ' #!/usr/bin/env python3', - '+import sys', - ' print("hi")', - }) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) - - local hunks = parser.parse_buffer(diff_buf) - - assert.are.equal(1, #hunks) - assert.are.equal('deploy', hunks[1].filename) - assert.are.equal('python', hunks[1].ft) - - delete_buffer(diff_buf) - os.remove(file_path) - vim.fn.delete(repo_root, 'rf') - end) - it('extracts file line numbers from @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -440,22 +409,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('extracts large line numbers from @@ header', function() - local bufnr = create_buffer({ - 'M lua/test.lua', - '@@ -100,20 +200,30 @@', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(100, hunks[1].file_old_start) - assert.are.equal(20, hunks[1].file_old_count) - assert.are.equal(200, hunks[1].file_new_start) - assert.are.equal(30, hunks[1].file_new_count) - delete_buffer(bufnr) - end) - it('defaults count to 1 when omitted in @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -487,18 +440,5 @@ describe('parser', function() assert.are.equal('/tmp/test-repo', hunks[1].repo_root) delete_buffer(bufnr) end) - - it('repo_root is nil when not available', function() - local bufnr = create_buffer({ - 'M lua/test.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.is_nil(hunks[1].repo_root) - delete_buffer(bufnr) - end) end) end) From 2d7d26a1bc4f14d953198b274009e700e74fe0d9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:15:42 -0500 Subject: [PATCH 06/55] docs(readme): mention vscode-diff algorithm and credit @esmuellert (#103) ## Problem The README doesn't mention the optional vscode-diff FFI backend for word-level intra-line accuracy, and the codediff.nvim acknowledgement doesn't credit the author by name. ## Solution Expand the intra-line feature bullet to mention vscode-diff with a link to codediff.nvim. Credit @esmuellert by name in the acknowledgements section. Also update the stale context padding reference in known limitations to match the current behavior. --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 996ee27..a3a7ae4 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ syntax highlighting. ## Features - Treesitter syntax highlighting in fugitive diffs and commit views -- Character-level intra-line diff highlighting +- Character-level intra-line diff highlighting (with optional + [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for + word-level accuracy) - `:Gdiff` unified diff against any revision - Background-only diff colors for `&diff` buffers - Inline merge conflict detection, highlighting, and resolution -- Vim syntax fallback, context padding, configurable blend/debounce +- Vim syntax fallback, configurable blend/debounce/priorities ## Requirements @@ -38,10 +40,9 @@ luarocks install diffs.nvim ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - To improve accuracy, `diffs.nvim` reads lines from disk before and after each - hunk for parsing context (`highlights.context`, enabled by default with 25 - lines). This resolves most boundary issues. Set - `highlights.context.enabled = false` to disable. + Context lines within the hunk (` ` prefix) provide syntactic context for the + parser. In rare cases, hunks that start or end mid-expression may produce + imperfect highlights due to treesitter error recovery. - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily @@ -64,7 +65,9 @@ luarocks install diffs.nvim # Acknowledgements - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) -- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) +- [@esmuellert](https://github.com/esmuellert) / + [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff + algorithm FFI backend for word-level intra-line accuracy - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - [`difftastic`](https://github.com/Wilfred/difftastic) - [`mini.diff`](https://github.com/echasnovski/mini.diff) From 59fcf14817e285789cc200895eaad922273c0d54 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:34:15 -0500 Subject: [PATCH 07/55] Docs/readme vscode diff (#104) From cc5a36883840ed27f6c632b884c2333ad2e4ddfd Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:30:13 -0500 Subject: [PATCH 08/55] fix(highlight): support combined diff format for unmerged files (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- lua/diffs/highlight.lua | 58 +++++--- lua/diffs/parser.lua | 35 +++-- spec/highlight_spec.lua | 302 ++++++++++++++++++++++++++++++++++++++++ spec/parser_spec.lua | 117 ++++++++++++++++ 4 files changed, 484 insertions(+), 28 deletions(-) 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', From 5c7e7f4bda04c67c230af5c95cb831e43a59e0e0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:40:18 -0500 Subject: [PATCH 09/55] doc: readme video preview (#107) closes #105 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a3a7ae4..a16f6bd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware syntax highlighting. -![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a) +![diffs.nvim preview](https://github.com/user-attachments/assets/56dbe55e-7789-407f-9bcf-c5f1ab9d4767) ## Features From bae6707c51bcde6525fda4c7643722df9b07f1cb Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:43:42 -0500 Subject: [PATCH 10/55] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a16f6bd..63e75da 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware syntax highlighting. -![diffs.nvim preview](https://github.com/user-attachments/assets/56dbe55e-7789-407f-9bcf-c5f1ab9d4767) +![diffs.nvim preview](https://github.com/user-attachments/assets/ac854c29-4a5e-468d-9d1b-3c284aeb2677) ## Features From 18405ddbfa9acad170d185741ddc47af3cf15dc7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:45:17 -0500 Subject: [PATCH 11/55] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 63e75da..f6d32b7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware syntax highlighting. -![diffs.nvim preview](https://github.com/user-attachments/assets/ac854c29-4a5e-468d-9d1b-3c284aeb2677) + ## Features From eb4b7f1a0b81cd9a50e5ba4c590831a51c5aaf30 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:51:29 -0500 Subject: [PATCH 12/55] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6d32b7..337081c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware syntax highlighting. - + ## Features From 4ce1e1786a9bd724fda5ab4aba528b64a790b11f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:39:38 -0500 Subject: [PATCH 13/55] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 337081c..3aa4f53 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware syntax highlighting. - + ## Features From 330e2bc9b89ebcc52b77a9fa960541c0cfbca81d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:14:28 -0500 Subject: [PATCH 14/55] feat: highlight commit buffers (#112) closes #109 `:G commit` buffers are now highlighted as follows: image Co-authored-by: Barrett Ruth --- plugin/diffs.lua | 2 +- spec/parser_spec.lua | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugin/diffs.lua b/plugin/diffs.lua index face556..794e41a 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() vim.api.nvim_create_autocmd('FileType', { - pattern = { 'fugitive', 'git' }, + pattern = { 'fugitive', 'git', 'gitcommit' }, callback = function(args) local diffs = require('diffs') if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index d88f9b7..401948b 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -542,6 +542,36 @@ describe('parser', function() delete_buffer(bufnr) end) + it('parses diff from gitcommit verbose buffer', function() + local bufnr = create_buffer({ + '', + '# Please enter the commit message for your changes.', + '#', + '# On branch main', + '# Changes to be committed:', + '#\tmodified: test.lua', + '#', + '# ------------------------ >8 ------------------------', + '# Do not modify or remove the line above.', + 'diff --git a/test.lua b/test.lua', + 'index abc1234..def5678 100644', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,3 +1,3 @@', + ' local function hello()', + '- print("hello world")', + '+ print("hello universe")', + ' return true', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(4, #hunks[1].lines) + delete_buffer(bufnr) + end) + it('stores repo_root on hunk when available', function() local bufnr = create_buffer({ 'M lua/test.lua', From 9a0b812f69a6aca115441c2de15fdb75f83b51f3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:59:13 -0500 Subject: [PATCH 15/55] performance improvements (#116) closes #111 --- .gitignore | 5 + README.md | 27 ++-- doc/diffs.nvim.txt | 18 +-- flake.lock | 43 ++++++ flake.nix | 35 +++++ lua/diffs/highlight.lua | 41 ++++++ lua/diffs/init.lua | 302 ++++++++++++++++++++++++++++++---------- lua/diffs/log.lua | 22 ++- lua/diffs/parser.lua | 21 ++- spec/init_spec.lua | 180 +++++++++++++++++++++++- 10 files changed, 590 insertions(+), 104 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index d14787c..13d3d92 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,9 @@ doc/tags CLAUDE.md .claude/ +bench/ node_modules/ + +result +result-* +.direnv/ diff --git a/README.md b/README.md index 3aa4f53..bf64d35 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ syntax highlighting. - `:Gdiff` unified diff against any revision - Background-only diff colors for `&diff` buffers - Inline merge conflict detection, highlighting, and resolution -- Vim syntax fallback, configurable blend/debounce/priorities +- Vim syntax fallback, configurable blend/priorities ## Requirements @@ -40,14 +40,24 @@ luarocks install diffs.nvim ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - Context lines within the hunk (` ` prefix) provide syntactic context for the - parser. In rare cases, hunks that start or end mid-expression may produce - imperfect highlights due to treesitter error recovery. + Context lines within the hunk provide syntactic context for the parser. In + rare cases, hunks that start or end mid-expression may produce imperfect + highlights due to treesitter error recovery. -- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event +- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily - painted. The buffer is then re-painted after `debounce_ms` milliseconds, - causing an unavoidable visual "flash" even when `debounce_ms = 0`. + painted. The decoration provider applies highlights on the next redraw cycle, + causing a brief visual "flash". + +- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation + (~4ms) are one-time costs per language per Neovim session. Each language pays + this cost on first encounter, which may cause a brief stutter when a diff + containing a new language first enters the viewport. + +- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages + without a treesitter parser) cannot run inside the decoration provider's + redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax + highlights for these hunks appear slightly delayed. - **Conflicting diff plugins**: `diffs.nvim` may not interact well with other plugins that modify diff highlighting. Known plugins that may conflict: @@ -74,4 +84,5 @@ luarocks install diffs.nvim - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [@phanen](https://github.com/phanen) - diff header highlighting, unknown - filetype fix, shebang/modeline detection, treesitter injection support + filetype fix, shebang/modeline detection, treesitter injection support, + decoration provider highlighting architecture diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index d1df476..5bf677e 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -52,7 +52,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: >lua vim.g.diffs = { debug = false, - debounce_ms = 0, hide_prefix = false, highlights = { background = true, @@ -109,11 +108,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Enable debug logging to |:messages| with `[diffs]` prefix. - {debounce_ms} (integer, default: 0) - Debounce delay in milliseconds for re-highlighting - after buffer changes. Lower values feel snappier - but use more CPU. - {hide_prefix} (boolean, default: false) Hide diff prefixes (`+`/`-`/` `) using virtual text overlay. Makes code appear without the @@ -611,7 +605,7 @@ Summary / commit detail views: ~ priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled - All priorities are configurable via |diffs.PrioritiesConfig| -4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events +4. A decoration provider re-highlights visible hunks on each redraw Diff mode views: ~ 1. `OptionSet diff` detects when any window enters diff mode @@ -637,14 +631,8 @@ the buffer briefly shows fugitive's default diff highlighting before diffs.nvim applies treesitter highlights. This occurs because diffs.nvim hooks into the `FileType fugitive` event, -which fires after vim-fugitive has already painted the buffer. Even with -`debounce_ms = 0`, the re-painting goes through Neovim's event loop. - -To minimize the flash, use a low debounce value: >lua - vim.g.diffs = { - debounce_ms = 0, - } -< +which fires after vim-fugitive has already painted the buffer. The +decoration provider applies highlights on the next redraw cycle. Conflicting Diff Plugins ~ *diffs-plugin-conflicts* diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0c2cb09 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770812194, + "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..db4c54c --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "diffs.nvim — syntax highlighting for diffs in Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + ]; + }; + }); + }; +} diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 8e80620..3b50055 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -64,6 +64,7 @@ end ---@class diffs.HunkOpts ---@field hide_prefix boolean ---@field highlights diffs.Highlights +---@field defer_vim_syntax? boolean ---@param bufnr integer ---@param ns integer @@ -283,6 +284,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) use_vim = false end + if use_vim and opts.defer_vim_syntax then + use_vim = false + end + ---@type table local covered_lines = {} @@ -488,4 +493,40 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end +---@param bufnr integer +---@param ns integer +---@param hunk diffs.Hunk +---@param opts diffs.HunkOpts +function M.highlight_hunk_vim_syntax(bufnr, ns, hunk, opts) + local p = opts.highlights.priorities + local pw = hunk.prefix_width or 1 + + if not hunk.ft or #hunk.lines == 0 then + return + end + + if #hunk.lines > opts.highlights.vim.max_lines then + return + end + + local code_lines = {} + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(pw + 1)) + end + + local covered_lines = {} + highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) + + for buf_line in pairs(covered_lines) do + local line = hunk.lines[buf_line - hunk.start_line + 1] + if line and #line > pw then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { + end_col = #line, + hl_group = 'DiffsClear', + priority = p.clear, + }) + end + end +end + return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 00dddca..08e035f 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -54,8 +54,7 @@ ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config ----@field debug boolean ----@field debounce_ms integer +---@field debug boolean|string ---@field hide_prefix boolean ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig @@ -107,7 +106,6 @@ end ---@type diffs.Config local default_config = { debug = false, - debounce_ms = 0, hide_prefix = false, highlights = { background = true, @@ -162,12 +160,26 @@ local config = vim.deepcopy(default_config) local initialized = false +---@diagnostic disable-next-line: missing-fields +local fast_hl_opts = {} ---@type diffs.HunkOpts + ---@type table local attached_buffers = {} ---@type table local diff_windows = {} +---@class diffs.HunkCacheEntry +---@field hunks diffs.Hunk[] +---@field tick integer +---@field highlighted table +---@field pending_clear boolean +---@field line_count integer +---@field byte_count integer + +---@type table +local hunk_cache = {} + ---@param bufnr integer ---@return boolean function M.is_fugitive_buffer(bufnr) @@ -177,53 +189,108 @@ end local dbg = log.dbg ---@param bufnr integer -local function highlight_buffer(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local hunks = parser.parse_buffer(bufnr) - dbg('found %d hunks in buffer %d', #hunks, bufnr) - for _, hunk in ipairs(hunks) do - highlight.highlight_hunk(bufnr, ns, hunk, { - hide_prefix = config.hide_prefix, - highlights = config.highlights, - }) +local function invalidate_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry then + entry.tick = -1 + entry.pending_clear = true end end ---@param bufnr integer ----@return fun() -local function create_debounced_highlight(bufnr) - local timer = nil ---@type table? - return function() - if timer then - timer:stop() ---@diagnostic disable-line: undefined-field - timer:close() ---@diagnostic disable-line: undefined-field - timer = nil - end - local t = vim.uv.new_timer() - if not t then - highlight_buffer(bufnr) +local function ensure_cache(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local tick = vim.api.nvim_buf_get_changedtick(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.tick == tick then + return + end + if entry and not entry.pending_clear then + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + if lc == entry.line_count and bc == entry.byte_count then + entry.tick = tick + entry.pending_clear = true + dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick) return end - timer = t - t:start( - config.debounce_ms, - 0, - vim.schedule_wrap(function() - if timer == t then - timer = nil - t:close() - end - if vim.api.nvim_buf_is_valid(bufnr) then - highlight_buffer(bufnr) - end - end) - ) end + local hunks = parser.parse_buffer(bufnr) + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + hunk_cache[bufnr] = { + hunks = hunks, + tick = tick, + highlighted = {}, + pending_clear = true, + line_count = lc, + byte_count = bc, + } + + local has_nil_ft = false + for _, hunk in ipairs(hunks) do + if not has_nil_ft and not hunk.ft and hunk.filename then + has_nil_ft = true + end + end + if has_nil_ft and vim.fn.did_filetype() ~= 0 then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then + dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr) + invalidate_cache(bufnr) + end + end) + end +end + +---@param hunks diffs.Hunk[] +---@param toprow integer +---@param botrow integer +---@return integer first +---@return integer last +local function find_visible_hunks(hunks, toprow, botrow) + local n = #hunks + if n == 0 then + return 0, 0 + end + + local lo, hi = 1, n + 1 + while lo < hi do + local mid = math.floor((lo + hi) / 2) + local h = hunks[mid] + local bottom = h.start_line - 1 + #h.lines - 1 + if bottom < toprow then + lo = mid + 1 + else + hi = mid + end + end + + if lo > n then + return 0, 0 + end + + local first = lo + local h = hunks[first] + local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + return 0, 0 + end + + local last = first + for i = first + 1, n do + h = hunks[i] + top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + break + end + last = i + end + + return first, last end local function compute_highlight_groups() @@ -327,8 +394,13 @@ local function init() local opts = vim.g.diffs or {} vim.validate({ - debug = { opts.debug, 'boolean', true }, - debounce_ms = { opts.debounce_ms, 'number', true }, + debug = { + opts.debug, + function(v) + return v == nil or type(v) == 'boolean' or type(v) == 'string' + end, + 'boolean or string (file path)', + }, hide_prefix = { opts.hide_prefix, 'boolean', true }, highlights = { opts.highlights, 'table', true }, }) @@ -441,9 +513,6 @@ local function init() end end - if opts.debounce_ms and opts.debounce_ms < 0 then - error('diffs: debounce_ms must be >= 0') - end if opts.highlights and opts.highlights.context @@ -498,13 +567,113 @@ local function init() config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) + fast_hl_opts = { + hide_prefix = config.hide_prefix, + highlights = vim.tbl_deep_extend('force', config.highlights, { + treesitter = { enabled = false }, + }), + defer_vim_syntax = true, + } + compute_highlight_groups() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() for bufnr, _ in pairs(attached_buffers) do - highlight_buffer(bufnr) + invalidate_cache(bufnr) + end + end, + }) + + vim.api.nvim_set_decoration_provider(ns, { + on_buf = function(_, bufnr) + if not attached_buffers[bufnr] then + return false + end + local t0 = config.debug and vim.uv.hrtime() or nil + ensure_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + entry.highlighted = {} + entry.pending_clear = false + end + if t0 then + dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6) + end + end, + on_win = function(_, _, bufnr, toprow, botrow) + if not attached_buffers[bufnr] then + return false + end + local entry = hunk_cache[bufnr] + if not entry then + return + end + local first, last = find_visible_hunks(entry.hunks, toprow, botrow) + if first == 0 then + return + end + local t0 = config.debug and vim.uv.hrtime() or nil + local deferred_syntax = {} + local count = 0 + for i = first, last do + if not entry.highlighted[i] then + local hunk = entry.hunks[i] + highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts) + entry.highlighted[i] = true + count = count + 1 + local has_syntax = hunk.lang and config.highlights.treesitter.enabled + local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled + if has_syntax or needs_vim then + table.insert(deferred_syntax, hunk) + end + end + end + if #deferred_syntax > 0 then + local tick = entry.tick + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local cur = hunk_cache[bufnr] + if not cur or cur.tick ~= tick then + return + end + local t1 = config.debug and vim.uv.hrtime() or nil + local full_opts = { + hide_prefix = config.hide_prefix, + highlights = config.highlights, + } + for _, hunk in ipairs(deferred_syntax) do + local start_row = hunk.start_line - 1 + local end_row = start_row + #hunk.lines + if hunk.header_start_line then + start_row = hunk.header_start_line - 1 + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row) + highlight.highlight_hunk(bufnr, ns, hunk, full_opts) + if not hunk.lang and hunk.ft then + highlight.highlight_hunk_vim_syntax(bufnr, ns, hunk, full_opts) + end + end + if t1 then + dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) + end + end) + end + if t0 and count > 0 then + dbg( + 'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)', + bufnr, + count, + first, + last, + (vim.uv.hrtime() - t0) / 1e6, + toprow, + botrow + ) end end, }) @@ -531,35 +700,13 @@ function M.attach(bufnr) dbg('attaching to buffer %d', bufnr) - local debounced = create_debounced_highlight(bufnr) - - highlight_buffer(bufnr) - - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { - buffer = bufnr, - callback = debounced, - }) - - vim.api.nvim_create_autocmd('Syntax', { - buffer = bufnr, - callback = function() - dbg('syntax event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) - - vim.api.nvim_create_autocmd('BufReadPost', { - buffer = bufnr, - callback = function() - dbg('BufReadPost event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) + ensure_cache(bufnr) vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, callback = function() attached_buffers[bufnr] = nil + hunk_cache[bufnr] = nil end, }) end @@ -567,7 +714,7 @@ end ---@param bufnr? integer function M.refresh(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - highlight_buffer(bufnr) + invalidate_cache(bufnr) end local DIFF_WINHIGHLIGHT = table.concat({ @@ -622,4 +769,11 @@ function M.get_conflict_config() return config.conflict end +M._test = { + find_visible_hunks = find_visible_hunks, + hunk_cache = hunk_cache, + ensure_cache = ensure_cache, + invalidate_cache = invalidate_cache, +} + return M diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index 08abcc6..525f578 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -1,10 +1,17 @@ local M = {} local enabled = false +local log_file = nil ----@param val boolean +---@param val boolean|string function M.set_enabled(val) - enabled = val + if type(val) == 'string' then + enabled = true + log_file = val + else + enabled = val + log_file = nil + end end ---@param msg string @@ -13,7 +20,16 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) + local formatted = '[diffs.nvim]: ' .. string.format(msg, ...) + if log_file then + local f = io.open(log_file, 'a') + if f then + f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n') + f:close() + end + else + vim.notify(formatted, vim.log.levels.DEBUG) + end end return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 6a04d38..c3b9aa8 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -19,6 +19,9 @@ local M = {} local dbg = require('diffs.log').dbg +---@type table +local ft_lang_cache = {} + ---@param filepath string ---@param n integer ---@return string[]? @@ -187,8 +190,18 @@ function M.parse_buffer(bufnr) if filename then flush_hunk() current_filename = filename - current_ft = get_ft_from_filename(filename, repo_root) - current_lang = current_ft and get_lang_from_ft(current_ft) or nil + local cache_key = (repo_root or '') .. '\0' .. filename + local cached = ft_lang_cache[cache_key] + if cached then + current_ft = cached.ft + current_lang = cached.lang + else + current_ft = get_ft_from_filename(filename, repo_root) + current_lang = current_ft and get_lang_from_ft(current_ft) or nil + if current_ft or vim.fn.did_filetype() == 0 then + ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang } + end + end if current_lang then dbg('file: %s -> lang: %s', filename, current_lang) elseif current_ft then @@ -254,4 +267,8 @@ function M.parse_buffer(bufnr) return hunks end +M._test = { + ft_lang_cache = ft_lang_cache, +} + return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 1bf71d1..139fb0d 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -24,7 +24,6 @@ describe('diffs', function() it('accepts full config', function() vim.g.diffs = { debug = true, - debounce_ms = 100, hide_prefix = false, highlights = { background = true, @@ -46,7 +45,7 @@ describe('diffs', function() it('accepts partial config', function() vim.g.diffs = { - debounce_ms = 25, + hide_prefix = true, } assert.has_no.errors(function() diffs.attach() @@ -152,6 +151,183 @@ describe('diffs', function() end) end) + describe('find_visible_hunks', function() + local find_visible_hunks = diffs._test.find_visible_hunks + + local function make_hunk(start_row, end_row, opts) + local lines = {} + for i = 1, end_row - start_row + 1 do + lines[i] = 'line' .. i + end + local h = { start_line = start_row + 1, lines = lines } + if opts and opts.header_start_line then + h.header_start_line = opts.header_start_line + end + return h + end + + it('returns (0, 0) for empty hunk list', function() + local first, last = find_visible_hunks({}, 0, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk fully inside viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('returns (0, 0) for single hunk fully above viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 20, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('returns (0, 0) for single hunk fully below viewport', function() + local h = make_hunk(50, 60) + local first, last = find_visible_hunks({ h }, 0, 20) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk partially visible at top edge', function() + local h = make_hunk(5, 15) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds single hunk partially visible at bottom edge', function() + local h = make_hunk(25, 35) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds subset of visible hunks', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(25, 30) + local h3 = make_hunk(55, 60) + local first, last = find_visible_hunks({ h1, h2, h3 }, 20, 40) + assert.are.equal(2, first) + assert.are.equal(2, last) + end) + + it('finds all hunks when all are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local h3 = make_hunk(25, 30) + local first, last = find_visible_hunks({ h1, h2, h3 }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(3, last) + end) + + it('returns (0, 0) when no hunks are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local first, last = find_visible_hunks({ h1, h2 }, 30, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('uses header_start_line for top boundary', function() + local h = make_hunk(5, 10, { header_start_line = 4 }) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds both adjacent hunks at viewport edge', function() + local h1 = make_hunk(10, 20) + local h2 = make_hunk(20, 30) + local first, last = find_visible_hunks({ h1, h2 }, 15, 25) + assert.are.equal(1, first) + assert.are.equal(2, last) + end) + end) + + describe('hunk_cache', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + it('creates entry on attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.is_number(entry.tick) + assert.is_true(entry.tick >= 0) + delete_buffer(bufnr) + end) + + it('is idempotent on repeated attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry1 = diffs._test.hunk_cache[bufnr] + local tick1 = entry1.tick + local hunks1 = entry1.hunks + diffs._test.ensure_cache(bufnr) + local entry2 = diffs._test.hunk_cache[bufnr] + assert.are.equal(tick1, entry2.tick) + assert.are.equal(hunks1, entry2.hunks) + delete_buffer(bufnr) + end) + + it('marks stale on invalidate', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + diffs._test.invalidate_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('evicts on buffer wipeout', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + assert.is_not_nil(diffs._test.hunk_cache[bufnr]) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(diffs._test.hunk_cache[bufnr]) + end) + + it('detects content change via tick', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs._test.ensure_cache(bufnr) + local tick_after = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after > tick_before) + delete_buffer(bufnr) + end) + end) + describe('diff mode', function() local function create_diff_window() vim.cmd('new') From 3990014a93ece886b0579af9d8faf7e02f424557 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Feb 2026 18:04:47 -0500 Subject: [PATCH 16/55] feat: add support for `diff` and other filetypes --- doc/diffs.nvim.txt | 16 ++++++++++++++-- lua/diffs/init.lua | 3 +++ lua/diffs/parser.lua | 23 ++++++++++++++++++++++- plugin/diffs.lua | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 5bf677e..6423f87 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -53,6 +53,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { debug = false, hide_prefix = false, + filetypes = { 'fugitive', 'git', 'gitcommit' }, highlights = { background = true, gutter = true, @@ -115,6 +116,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: is also enabled, the overlay inherits the line's background color. + {filetypes} (table, default: {'fugitive','git','gitcommit'}) + List of filetypes that trigger attachment. Add + `'diff'` to enable highlighting in plain `.diff` + and `.patch` files: >lua + vim.g.diffs = { + filetypes = { + 'fugitive', 'git', 'gitcommit', 'diff', + }, + } +< + {highlights} (table, default: see below) Controls which highlight features are enabled. See |diffs.Highlights| for fields. @@ -588,8 +600,8 @@ refresh({bufnr}) *diffs.refresh()* IMPLEMENTATION *diffs-implementation* Summary / commit detail views: ~ -1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) - triggers |diffs.attach()| +1. `FileType` autocmd for configured filetypes (see {filetypes}) triggers + |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached. 2. The buffer is parsed to detect file headers (`M path/to/file`, `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) 3. For each hunk: diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 08e035f..818a49b 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -56,6 +56,7 @@ ---@class diffs.Config ---@field debug boolean|string ---@field hide_prefix boolean +---@field filetypes string[] ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig ---@field conflict diffs.ConflictConfig @@ -107,6 +108,7 @@ end local default_config = { debug = false, hide_prefix = false, + filetypes = { 'fugitive', 'git', 'gitcommit' }, highlights = { background = true, gutter = true, @@ -402,6 +404,7 @@ local function init() 'boolean or string (file path)', }, hide_prefix = { opts.hide_prefix, 'boolean', true }, + filetypes = { opts.filetypes, 'table', true }, highlights = { opts.highlights, 'table', true }, }) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index c3b9aa8..8def0e6 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -151,6 +151,11 @@ function M.parse_buffer(bufnr) local file_new_start = nil ---@type integer? local file_new_count = nil + ---@type integer? + local old_remaining = nil + ---@type integer? + local new_remaining = nil + local is_unified_diff = false local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -183,11 +188,15 @@ function M.parse_buffer(bufnr) file_old_count = nil file_new_start = nil file_new_count = nil + old_remaining = nil + new_remaining = nil end for i, line in ipairs(lines) do - local filename = line:match('^[MADRCU%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') + local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') + local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file if filename then + is_unified_diff = diff_git_file ~= nil flush_hunk() current_filename = filename local cache_key = (repo_root or '') .. '\0' .. filename @@ -223,6 +232,8 @@ function M.parse_buffer(bufnr) file_old_count = tonumber(hc) or 1 file_new_start = tonumber(hs2) file_new_count = tonumber(hc2) or 1 + old_remaining = file_old_count + new_remaining = file_new_count end else local hs2, hc2 = line:match('%+(%d+),?(%d*) @@') @@ -243,6 +254,16 @@ function M.parse_buffer(bufnr) local prefix = line:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then table.insert(hunk_lines, line) + if old_remaining and (prefix == ' ' or prefix == '-') then + old_remaining = old_remaining - 1 + end + if new_remaining and (prefix == ' ' or prefix == '+') then + new_remaining = new_remaining - 1 + end + elseif line == '' and is_unified_diff and old_remaining and old_remaining > 0 and new_remaining and new_remaining > 0 then + table.insert(hunk_lines, ' ') + old_remaining = old_remaining - 1 + new_remaining = new_remaining - 1 elseif line == '' or line:match('^[MADRC%?!]%s+') diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 794e41a..25a9ee6 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() vim.api.nvim_create_autocmd('FileType', { - pattern = { 'fugitive', 'git', 'gitcommit' }, + pattern = (vim.g.diffs or {}).filetypes or { 'fugitive', 'git', 'gitcommit' }, callback = function(args) local diffs = require('diffs') if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then From 5d3bbc3631504910c7161ba8effa70f77185fee5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Feb 2026 18:10:33 -0500 Subject: [PATCH 17/55] fix(ci): styling --- lua/diffs/parser.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 8def0e6..daa6fd3 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -260,7 +260,14 @@ function M.parse_buffer(bufnr) if new_remaining and (prefix == ' ' or prefix == '+') then new_remaining = new_remaining - 1 end - elseif line == '' and is_unified_diff and old_remaining and old_remaining > 0 and new_remaining and new_remaining > 0 then + elseif + line == '' + and is_unified_diff + and old_remaining + and old_remaining > 0 + and new_remaining + and new_remaining > 0 + then table.insert(hunk_lines, ' ') old_remaining = old_remaining - 1 new_remaining = new_remaining - 1 From 3d640c207bf21c671bac717a532e2ff9b5cf91d9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:12:01 -0500 Subject: [PATCH 18/55] feat: add neogit support (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TODO 1. docs (vimdoc + readme) - this is a non-trivial feature 2. push luarocks version ## Problem diffs.nvim only activates on `fugitive`, `git`, and `gitcommit` filetypes. Neogit uses its own custom filetypes (`NeogitStatus`, `NeogitCommitView`, `NeogitDiffView`) and doesn't set `b:git_dir`, so the plugin never attaches and repo root resolution fails for filetype detection within diff hunks. ## Solution Two changes: 1. **`lua/diffs/init.lua`** — Add the three Neogit filetypes to the default `filetypes` list. The `FileType` autocmd in `plugin/diffs.lua` already handles them correctly since the `is_fugitive_buffer` guard only applies to the `git` filetype. 2. **`lua/diffs/parser.lua`** — Add a CWD-based fallback in `get_repo_root()`. After the existing `b:diffs_repo_root` and `b:git_dir` checks, fall back to `vim.fn.getcwd()` via `git.get_repo_root()` (already cached). Without this, the parser can't resolve filetypes for files in Neogit buffers. Neogit's expanded diffs use standard unified diff format, so the parser handles them without modification. Closes #110. --- README.md | 22 +++++++- doc/diffs.nvim.txt | 87 ++++++++++++++++++++++------- lua/diffs/highlight.lua | 13 +---- lua/diffs/init.lua | 119 ++++++++++++++++++++++++++++++++++++++-- lua/diffs/parser.lua | 12 +++- plugin/diffs.lua | 2 +- spec/highlight_spec.lua | 34 ++++++------ spec/parser_spec.lua | 81 +++++++++++++++++++++++++++ 8 files changed, 314 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index bf64d35..0cce463 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ **Syntax highlighting for diffs in Neovim** -Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware -syntax highlighting. +Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive), +[Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode +with language-aware syntax highlighting. ## Features -- Treesitter syntax highlighting in fugitive diffs and commit views +- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype - Character-level intra-line diff highlighting (with optional [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for word-level accuracy) @@ -37,6 +38,21 @@ luarocks install diffs.nvim :help diffs.nvim ``` +## FAQ + +**Does diffs.nvim support vim-fugitive/Neogit?** + +Yes. Enable it in your config: + +```lua +vim.g.diffs = { + fugitive = true, + neogit = true, +} +``` + +See the documentation for more information. + ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6423f87..116ca3e 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -7,8 +7,8 @@ License: MIT INTRODUCTION *diffs.nvim* diffs.nvim adds syntax highlighting to diff views. It overlays language-aware -highlights on top of default diff highlighting in vim-fugitive and Neovim's -built-in diff mode. +highlights on top of default diff highlighting in vim-fugitive, Neogit, and +Neovim's built-in diff mode. Features: ~ - Syntax highlighting in |:Git| summary diffs and commit detail views @@ -53,7 +53,9 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { debug = false, hide_prefix = false, - filetypes = { 'fugitive', 'git', 'gitcommit' }, + fugitive = false, + neogit = false, + extra_filetypes = {}, highlights = { background = true, gutter = true, @@ -83,10 +85,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, overrides = {}, }, - fugitive = { - horizontal = 'du', - vertical = 'dU', - }, conflict = { enabled = true, disable_diagnostics = true, @@ -116,14 +114,36 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: is also enabled, the overlay inherits the line's background color. - {filetypes} (table, default: {'fugitive','git','gitcommit'}) - List of filetypes that trigger attachment. Add - `'diff'` to enable highlighting in plain `.diff` - and `.patch` files: >lua + {fugitive} (boolean|table, default: false) + Enable vim-fugitive integration. Accepts + `true`, `false`, or a table with sub-options + (see |diffs.FugitiveConfig|). When enabled, + the `fugitive` filetype is active and status + buffer keymaps are registered. >lua + vim.g.diffs = { fugitive = true } vim.g.diffs = { - filetypes = { - 'fugitive', 'git', 'gitcommit', 'diff', - }, + fugitive = { horizontal = 'dd' }, + } +< + + {neogit} (boolean|table, default: false) + Enable Neogit integration. Accepts `true`, + `false`, or `{ enabled = false }`. When + enabled, `NeogitStatus`, `NeogitCommitView`, + and `NeogitDiffView` filetypes are active and + Neogit highlight overrides are applied. See + |diffs-neogit|. >lua + vim.g.diffs = { neogit = false } +< + + {extra_filetypes} (table, default: {}) + Additional filetypes to attach to, beyond the + built-in `git`, `gitcommit`, and any enabled + integration filetypes. Use this to enable + highlighting in plain `.diff` / `.patch` + files: >lua + vim.g.diffs = { + extra_filetypes = { 'diff' }, } < @@ -131,10 +151,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Controls which highlight features are enabled. See |diffs.Highlights| for fields. - {fugitive} (table, default: see below) - Fugitive status buffer keymap options. - See |diffs.FugitiveConfig| for fields. - {conflict} (table, default: see below) Inline merge conflict resolution options. See |diffs.ConflictConfig| for fields. @@ -422,12 +438,20 @@ Configuration: ~ >lua vim.g.diffs = { fugitive = { + enabled = true, -- false to disable fugitive integration entirely horizontal = 'du', -- keymap for horizontal split, false to disable vertical = 'dU', -- keymap for vertical split, false to disable }, } < Fields: ~ + {enabled} (boolean, default: false) + Enable fugitive integration. When false, the + `fugitive` filetype is excluded and no status + buffer keymaps are registered. Shorthand: + `fugitive = false` is equivalent to + `fugitive = { enabled = false }`. + {horizontal} (string|false, default: 'du') Keymap for unified diff in horizontal split. Set to `false` to disable. @@ -579,6 +603,31 @@ The working file buffer is modified in place; save it when ready. Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed automatically after each resolution. +============================================================================== +NEOGIT *diffs-neogit* + +diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of +the box. Enable Neogit support in your config: >lua + vim.g.diffs = { neogit = true } +< + +When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the +status view), diffs.nvim applies treesitter syntax highlighting and +intra-line diffs to the hunk lines, just as it does for fugitive. + +Neogit highlight overrides: ~ +On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff +highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`, +`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by +setting them to empty (`{}`). This gives diffs.nvim sole control of diff +line visuals. The overrides are reapplied on `ColorScheme` since Neogit +re-defines its groups then. When `neogit = false`, no highlight overrides +are applied. + +Deprecated: ~ +The `filetypes` config key still works but is deprecated and will be +removed in 0.3.0. Use `fugitive`, `neogit`, and `extra_filetypes` instead. + ============================================================================== API *diffs-api* @@ -600,7 +649,7 @@ refresh({bufnr}) *diffs.refresh()* IMPLEMENTATION *diffs-implementation* Summary / commit detail views: ~ -1. `FileType` autocmd for configured filetypes (see {filetypes}) triggers +1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached. 2. The buffer is parsed to detect file headers (`M path/to/file`, `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 3b50055..bbab066 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -443,18 +443,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if opts.highlights.background and is_diff_line then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_row = buf_line + 1, - hl_group = line_hl, - hl_eol = true, - priority = p.line_bg, - }) + local ext = { line_hl_group = line_hl, priority = p.line_bg } if opts.highlights.gutter then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - number_hl_group = number_hl, - priority = p.line_bg, - }) + ext.number_hl_group = number_hl end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, ext) end if is_marker and line_len > pw then diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 818a49b..d677d44 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -33,9 +33,13 @@ ---@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig +---@field enabled boolean ---@field horizontal string|false ---@field vertical string|false +---@class diffs.NeogitConfig +---@field enabled boolean + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -56,9 +60,11 @@ ---@class diffs.Config ---@field debug boolean|string ---@field hide_prefix boolean ----@field filetypes string[] +---@field filetypes? string[] @deprecated use fugitive, neogit, extra_filetypes +---@field extra_filetypes string[] ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig +---@field neogit diffs.NeogitConfig ---@field conflict diffs.ConflictConfig ---@class diffs @@ -108,7 +114,7 @@ end local default_config = { debug = false, hide_prefix = false, - filetypes = { 'fugitive', 'git', 'gitcommit' }, + extra_filetypes = {}, highlights = { background = true, gutter = true, @@ -137,9 +143,13 @@ local default_config = { }, }, fugitive = { + enabled = false, horizontal = 'du', vertical = 'dU', }, + neogit = { + enabled = false, + }, conflict = { enabled = true, disable_diagnostics = true, @@ -188,6 +198,31 @@ function M.is_fugitive_buffer(bufnr) return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil end +---@param opts table +---@return string[] +function M.compute_filetypes(opts) + if opts.filetypes then + return opts.filetypes + end + local fts = { 'git', 'gitcommit' } + local fug = opts.fugitive + if fug == true or (type(fug) == 'table' and fug.enabled ~= false) then + table.insert(fts, 'fugitive') + end + local neo = opts.neogit + if neo == true or (type(neo) == 'table' and neo.enabled ~= false) then + table.insert(fts, 'NeogitStatus') + table.insert(fts, 'NeogitCommitView') + table.insert(fts, 'NeogitDiffView') + end + if type(opts.extra_filetypes) == 'table' then + for _, ft in ipairs(opts.extra_filetypes) do + table.insert(fts, ft) + end + end + return fts +end + local dbg = log.dbg ---@param bufnr integer @@ -387,6 +422,34 @@ local function compute_highlight_groups() end end +local neogit_attached = false + +local neogit_hl_groups = { + 'NeogitDiffAdd', + 'NeogitDiffAddCursor', + 'NeogitDiffAddHighlight', + 'NeogitDiffDelete', + 'NeogitDiffDeleteCursor', + 'NeogitDiffDeleteHighlight', + 'NeogitDiffContext', + 'NeogitDiffContextCursor', + 'NeogitDiffContextHighlight', + 'NeogitDiffHeader', + 'NeogitDiffHeaderHighlight', + 'NeogitHunkHeader', + 'NeogitHunkHeaderCursor', + 'NeogitHunkHeaderHighlight', + 'NeogitHunkMergeHeader', + 'NeogitHunkMergeHeaderCursor', + 'NeogitHunkMergeHeaderHighlight', +} + +local function override_neogit_highlights() + for _, name in ipairs(neogit_hl_groups) do + vim.api.nvim_set_hl(0, name, {}) + end +end + local function init() if initialized then return @@ -395,6 +458,35 @@ local function init() local opts = vim.g.diffs or {} + if opts.filetypes then + vim.deprecate( + 'vim.g.diffs.filetypes', + 'fugitive, neogit, and extra_filetypes', + '0.3.0', + 'diffs.nvim' + ) + end + + if opts.fugitive == true then + opts.fugitive = { enabled = true } + elseif opts.fugitive == false then + opts.fugitive = { enabled = false } + elseif opts.fugitive == nil then + opts.fugitive = nil + elseif type(opts.fugitive) == 'table' and opts.fugitive.enabled == nil then + opts.fugitive.enabled = true + end + + if opts.neogit == true then + opts.neogit = { enabled = true } + elseif opts.neogit == false then + opts.neogit = { enabled = false } + elseif opts.neogit == nil then + opts.neogit = nil + elseif type(opts.neogit) == 'table' and opts.neogit.enabled == nil then + opts.neogit.enabled = true + end + vim.validate({ debug = { opts.debug, @@ -404,7 +496,9 @@ local function init() 'boolean or string (file path)', }, hide_prefix = { opts.hide_prefix, 'boolean', true }, - filetypes = { opts.filetypes, 'table', true }, + fugitive = { opts.fugitive, 'table', true }, + neogit = { opts.neogit, 'table', true }, + extra_filetypes = { opts.extra_filetypes, 'table', true }, highlights = { opts.highlights, 'table', true }, }) @@ -472,23 +566,30 @@ local function init() if opts.fugitive then vim.validate({ + ['fugitive.enabled'] = { opts.fugitive.enabled, 'boolean', true }, ['fugitive.horizontal'] = { opts.fugitive.horizontal, function(v) - return v == false or type(v) == 'string' + return v == nil or v == false or type(v) == 'string' end, 'string or false', }, ['fugitive.vertical'] = { opts.fugitive.vertical, function(v) - return v == false or type(v) == 'string' + return v == nil or v == false or type(v) == 'string' end, 'string or false', }, }) end + if opts.neogit then + vim.validate({ + ['neogit.enabled'] = { opts.neogit.enabled, 'boolean', true }, + }) + end + if opts.conflict then vim.validate({ ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, @@ -583,6 +684,9 @@ local function init() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() + if neogit_attached then + vim.schedule(override_neogit_highlights) + end for bufnr, _ in pairs(attached_buffers) do invalidate_cache(bufnr) end @@ -701,6 +805,11 @@ function M.attach(bufnr) end attached_buffers[bufnr] = true + if not neogit_attached and config.neogit.enabled and vim.bo[bufnr].filetype:match('^Neogit') then + neogit_attached = true + vim.schedule(override_neogit_highlights) + end + dbg('attaching to buffer %d', bufnr) ensure_cache(bufnr) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index daa6fd3..fc9eeaa 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -110,7 +110,9 @@ local function get_repo_root(bufnr) return vim.fn.fnamemodify(git_dir, ':h') end - return nil + local cwd = vim.fn.getcwd() + local git = require('diffs.git') + return git.get_repo_root(cwd .. '/.') end ---@param bufnr integer @@ -194,7 +196,13 @@ function M.parse_buffer(bufnr) for i, line in ipairs(lines) do local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') - local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file + local neogit_file = line:match('^modified%s+(.+)$') + or line:match('^new file%s+(.+)$') + or line:match('^deleted%s+(.+)$') + or line:match('^renamed%s+(.+)$') + or line:match('^copied%s+(.+)$') + local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$') + local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file if filename then is_unified_diff = diff_git_file ~= nil flush_hunk() diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 25a9ee6..e572cf5 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() vim.api.nvim_create_autocmd('FileType', { - pattern = (vim.g.diffs or {}).filetypes or { 'fugitive', 'git', 'gitcommit' }, + pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), callback = function(args) local diffs = require('diffs') if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 42cd0d1..36f3acd 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -287,7 +287,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -320,7 +320,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsDelete' then + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -386,7 +386,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -500,7 +500,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -560,7 +560,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('uses hl_group not line_hl_group for line backgrounds', function() + it('uses line_hl_group for line backgrounds', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -582,17 +582,19 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) + local found = false for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - assert.is_true(d.hl_eol == true) - assert.is_nil(d.line_hl_group) + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + found = true + assert.is_nil(d.hl_eol) end end + assert.is_true(found) delete_buffer(bufnr) end) - it('hl_eol background extmarks are multiline so hl_eol takes effect', function() + it('line_hl_group background extmarks are single-line', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -616,8 +618,8 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - assert.is_true(d.end_row > mark[2]) + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + assert.is_nil(d.end_row) end end delete_buffer(bufnr) @@ -771,7 +773,7 @@ describe('highlight', function() if d then if d.hl_group == 'DiffsClear' then table.insert(priorities.clear, d.priority) - elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then + elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then table.insert(priorities.char_bg, d.priority) @@ -871,8 +873,8 @@ describe('highlight', function() 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 + if mark[4] and mark[4].line_hl_group then + line_bgs[mark[2]] = mark[4].line_hl_group end end assert.is_nil(line_bgs[1]) @@ -1063,8 +1065,8 @@ describe('highlight', function() 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 + if d and d.line_hl_group then + line_bgs[mark[2]] = d.line_hl_group end if d and d.number_hl_group then gutter_hls[mark[2]] = d.number_hl_group diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 401948b..f297f58 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -587,5 +587,86 @@ describe('parser', function() assert.are.equal('/tmp/test-repo', hunks[1].repo_root) delete_buffer(bufnr) end) + + it('detects neogit modified prefix', function() + local bufnr = create_buffer({ + 'modified hello.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects neogit new file prefix', function() + local bufnr = create_buffer({ + 'new file hello.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects neogit deleted prefix', function() + local bufnr = create_buffer({ + 'deleted hello.lua', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects bare filename for untracked files', function() + local bufnr = create_buffer({ + 'newfile.rs', + '@@ -0,0 +1,3 @@', + '+fn main() {', + '+ println!("hello");', + '+}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('newfile.rs', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('does not match section headers as bare filenames', function() + local bufnr = create_buffer({ + 'Untracked files (1)', + 'newfile.rs', + '@@ -0,0 +1,3 @@', + '+fn main() {', + '+ println!("hello");', + '+}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('newfile.rs', hunks[1].filename) + delete_buffer(bufnr) + end) end) end) From cb38865b96891da39be6d33b741247028adb6e79 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:48:19 -0500 Subject: [PATCH 19/55] fix: warn users when fugitive/neogit/diff integrations are unconfigured (#123) ## Problem Commit 0f27488 changed fugitive and neogit integrations from enabled by default to disabled by default. Users who never explicitly set these keys in their config saw no deprecation notice and silently lost integration support. The existing deprecation warning only fires for the old `filetypes` key, missing the far more common case of users who had no explicit config at all. ## Solution Add an ephemeral migration check in `init()` that emits a `vim.notify` warning at WARN level when `fugitive`, `neogit`, and `diff` (via `extra_filetypes`) are all absent from the user's config. This covers the gap between the old `filetypes` deprecation and users who relied on implicit defaults. To be removed in 0.3.0. --- lua/diffs/init.lua | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d677d44..4140366 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -467,6 +467,31 @@ local function init() ) end + if not opts.filetypes and opts.fugitive == nil and opts.neogit == nil then + local has_diff_ft = false + if type(opts.extra_filetypes) == 'table' then + for _, ft in ipairs(opts.extra_filetypes) do + if ft == 'diff' then + has_diff_ft = true + break + end + end + end + if not has_diff_ft then + vim.notify( + '[diffs.nvim] fugitive, neogit, and diff filetypes are now opt-in.\n' + .. 'Add the integrations you use to your config:\n\n' + .. ' vim.g.diffs = {\n' + .. ' fugitive = true,\n' + .. ' neogit = true,\n' + .. " extra_filetypes = { 'diff' },\n" + .. ' }\n\n' + .. 'This warning will be removed in 0.3.0.', + vim.log.levels.WARN + ) + end + end + if opts.fugitive == true then opts.fugitive = { enabled = true } elseif opts.fugitive == false then From 028ba5314e4f1b1d620a82fef9799db3047bf572 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:27:39 -0500 Subject: [PATCH 20/55] fix(highlight): revert line backgrounds to hl_group+hl_eol (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The neogit commit (3d640c2) switched line background extmarks from `hl_group`+`hl_eol` to `line_hl_group`. Due to [neovim#31151][1], `line_hl_group` bg overrides `hl_group` bg regardless of extmark priority. This made `DiffsAddText`/`DiffsDeleteText` intra-line highlights invisible beneath line backgrounds — the extmarks were placed correctly but Neovim rendered the line bg on top. [1]: https://github.com/neovim/neovim/issues/31151 ## Solution Revert line backgrounds to `hl_group`+`hl_eol` where priority stacking works correctly. Keep `number_hl_group` in a separate point extmark to prevent gutter color bleeding to adjacent lines. The Neogit highlight override (clearing their groups to `{}`) is independent and unaffected. --- lua/diffs/highlight.lua | 13 +++++++--- spec/highlight_spec.lua | 55 +++++++++-------------------------------- 2 files changed, 22 insertions(+), 46 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index bbab066..3b50055 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -443,11 +443,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if opts.highlights.background and is_diff_line then - local ext = { line_hl_group = line_hl, priority = p.line_bg } + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_row = buf_line + 1, + hl_group = line_hl, + hl_eol = true, + priority = p.line_bg, + }) if opts.highlights.gutter then - ext.number_hl_group = number_hl + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + number_hl_group = number_hl, + priority = p.line_bg, + }) end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, ext) end if is_marker and line_len > pw then diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 36f3acd..00a0774 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -287,7 +287,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -320,7 +320,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + if mark[4] and mark[4].hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -386,7 +386,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -500,7 +500,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -560,7 +560,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('uses line_hl_group for line backgrounds', function() + it('uses hl_group with hl_eol for line backgrounds', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -585,46 +585,14 @@ describe('highlight', function() local found = false for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then found = true - assert.is_nil(d.hl_eol) end end assert.is_true(found) delete_buffer(bufnr) end) - it('line_hl_group background extmarks are single-line', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,1 @@', - '-local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local y = 2' }, - } - - 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] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then - assert.is_nil(d.end_row) - end - end - delete_buffer(bufnr) - end) - it('number_hl_group does not bleed to adjacent lines', function() local bufnr = create_buffer({ '@@ -1,3 +1,3 @@', @@ -773,7 +741,7 @@ describe('highlight', function() if d then if d.hl_group == 'DiffsClear' then table.insert(priorities.clear, d.priority) - elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then + elseif (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then table.insert(priorities.char_bg, d.priority) @@ -873,8 +841,9 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local line_bgs = {} for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group then - line_bgs[mark[2]] = mark[4].line_hl_group + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then + line_bgs[mark[2]] = d.hl_group end end assert.is_nil(line_bgs[1]) @@ -1065,8 +1034,8 @@ describe('highlight', function() local marker_text = {} for _, mark in ipairs(extmarks) do local d = mark[4] - if d and d.line_hl_group then - line_bgs[mark[2]] = d.line_hl_group + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') 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 From a00993820f0d7df9cb13132337d483e1675e8adc Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:55:54 -0500 Subject: [PATCH 21/55] docs: add lazy.nvim installation FAQ (#125) ## Problem Users attempt to lazy-load diffs.nvim with `event`, `ft`, `lazy`, or `keys` options, which interferes with the plugin's own `FileType` autocmd registered at startup. ## Solution Add a FAQ entry with a correct lazy.nvim snippet using `init` and a note explaining that the plugin lazy-loads itself. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 0cce463..0a45156 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,22 @@ luarocks install diffs.nvim ## FAQ +**How do I install with lazy.nvim?** + +```lua +{ + 'barrettruth/diffs.nvim', + init = function() + vim.g.diffs = { + ... + } + end, +} +``` + +Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to +control loading - `diffs.nvim` lazy-loads itself. + **Does diffs.nvim support vim-fugitive/Neogit?** Yes. Enable it in your config: From cbc93f9eaa9b1737e114c00ad376b4a1bd61e1de Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:42:38 -0500 Subject: [PATCH 22/55] refactor: remove enabled field from fugitive/neogit config (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Users had to pass `enabled = true` or `enabled = false` inside fugitive/neogit config tables, which was redundant — table presence already implied the integration should be active. ## Solution Remove the `enabled` field from the public API. Table presence now implies enabled, `false` disables, `true` expands to sub-defaults. The `enabled` field is still accepted for backward compatibility. Added 20 `compute_filetypes` tests covering all config shapes (true, false, table, nil, backward-compat enabled field). Updated docs and type annotations. --- doc/diffs.nvim.txt | 37 +++++++++---------- lua/diffs/init.lua | 83 +++++++++++++++++++++--------------------- spec/init_spec.lua | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 60 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 116ca3e..1b816f1 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -115,11 +115,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: background color. {fugitive} (boolean|table, default: false) - Enable vim-fugitive integration. Accepts - `true`, `false`, or a table with sub-options - (see |diffs.FugitiveConfig|). When enabled, - the `fugitive` filetype is active and status - buffer keymaps are registered. >lua + Enable vim-fugitive integration. Pass `true` + for defaults, `false` to disable, or a table + with sub-options (see |diffs.FugitiveConfig|). + Passing a table implicitly enables the + integration — no `enabled` field needed. + When active, the `fugitive` filetype is + registered and status buffer keymaps are set. >lua vim.g.diffs = { fugitive = true } vim.g.diffs = { fugitive = { horizontal = 'dd' }, @@ -127,13 +129,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: < {neogit} (boolean|table, default: false) - Enable Neogit integration. Accepts `true`, - `false`, or `{ enabled = false }`. When - enabled, `NeogitStatus`, `NeogitCommitView`, - and `NeogitDiffView` filetypes are active and - Neogit highlight overrides are applied. See - |diffs-neogit|. >lua - vim.g.diffs = { neogit = false } + Enable Neogit integration. Pass `true` or + `{}` to enable, `false` to disable. When + active, `NeogitStatus`, `NeogitCommitView`, + and `NeogitDiffView` filetypes are registered + and Neogit highlight overrides are applied. + See |diffs-neogit|. >lua + vim.g.diffs = { neogit = true } < {extra_filetypes} (table, default: {}) @@ -438,20 +440,15 @@ Configuration: ~ >lua vim.g.diffs = { fugitive = { - enabled = true, -- false to disable fugitive integration entirely horizontal = 'du', -- keymap for horizontal split, false to disable vertical = 'dU', -- keymap for vertical split, false to disable }, } < - Fields: ~ - {enabled} (boolean, default: false) - Enable fugitive integration. When false, the - `fugitive` filetype is excluded and no status - buffer keymaps are registered. Shorthand: - `fugitive = false` is equivalent to - `fugitive = { enabled = false }`. + Passing a table enables fugitive integration. Use `fugitive = false` + to disable. There is no `enabled` field; table presence is sufficient. + Fields: ~ {horizontal} (string|false, default: 'du') Keymap for unified diff in horizontal split. Set to `false` to disable. diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 4140366..a293cf9 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -33,12 +33,10 @@ ---@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig ----@field enabled boolean ---@field horizontal string|false ---@field vertical string|false ---@class diffs.NeogitConfig ----@field enabled boolean ---@class diffs.ConflictKeymaps ---@field ours string|false @@ -63,8 +61,8 @@ ---@field filetypes? string[] @deprecated use fugitive, neogit, extra_filetypes ---@field extra_filetypes string[] ---@field highlights diffs.Highlights ----@field fugitive diffs.FugitiveConfig ----@field neogit diffs.NeogitConfig +---@field fugitive diffs.FugitiveConfig|false +---@field neogit diffs.NeogitConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -142,14 +140,8 @@ local default_config = { char_bg = 201, }, }, - fugitive = { - enabled = false, - horizontal = 'du', - vertical = 'dU', - }, - neogit = { - enabled = false, - }, + fugitive = false, + neogit = false, conflict = { enabled = true, disable_diagnostics = true, @@ -492,24 +484,28 @@ local function init() end end + local fugitive_defaults = { horizontal = 'du', vertical = 'dU' } if opts.fugitive == true then - opts.fugitive = { enabled = true } - elseif opts.fugitive == false then - opts.fugitive = { enabled = false } - elseif opts.fugitive == nil then - opts.fugitive = nil - elseif type(opts.fugitive) == 'table' and opts.fugitive.enabled == nil then - opts.fugitive.enabled = true + opts.fugitive = vim.deepcopy(fugitive_defaults) + elseif type(opts.fugitive) == 'table' then + if opts.fugitive.enabled == false then + opts.fugitive = false + else + ---@diagnostic disable-next-line: inject-field + opts.fugitive.enabled = nil + opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults) + end end if opts.neogit == true then - opts.neogit = { enabled = true } - elseif opts.neogit == false then - opts.neogit = { enabled = false } - elseif opts.neogit == nil then - opts.neogit = nil - elseif type(opts.neogit) == 'table' and opts.neogit.enabled == nil then - opts.neogit.enabled = true + opts.neogit = {} + elseif type(opts.neogit) == 'table' then + if opts.neogit.enabled == false then + opts.neogit = false + else + ---@diagnostic disable-next-line: inject-field + opts.neogit.enabled = nil + end end vim.validate({ @@ -521,8 +517,20 @@ local function init() 'boolean or string (file path)', }, hide_prefix = { opts.hide_prefix, 'boolean', true }, - fugitive = { opts.fugitive, 'table', true }, - neogit = { opts.neogit, 'table', true }, + fugitive = { + opts.fugitive, + function(v) + return v == nil or v == false or type(v) == 'table' + end, + 'table or false', + }, + neogit = { + opts.neogit, + function(v) + return v == nil or v == false or type(v) == 'table' + end, + 'table or false', + }, extra_filetypes = { opts.extra_filetypes, 'table', true }, highlights = { opts.highlights, 'table', true }, }) @@ -589,18 +597,19 @@ local function init() end end - if opts.fugitive then + if type(opts.fugitive) == 'table' then + ---@type diffs.FugitiveConfig + local fug = opts.fugitive vim.validate({ - ['fugitive.enabled'] = { opts.fugitive.enabled, 'boolean', true }, ['fugitive.horizontal'] = { - opts.fugitive.horizontal, + fug.horizontal, function(v) return v == nil or v == false or type(v) == 'string' end, 'string or false', }, ['fugitive.vertical'] = { - opts.fugitive.vertical, + fug.vertical, function(v) return v == nil or v == false or type(v) == 'string' end, @@ -609,12 +618,6 @@ local function init() }) end - if opts.neogit then - vim.validate({ - ['neogit.enabled'] = { opts.neogit.enabled, 'boolean', true }, - }) - end - if opts.conflict then vim.validate({ ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, @@ -830,7 +833,7 @@ function M.attach(bufnr) end attached_buffers[bufnr] = true - if not neogit_attached and config.neogit.enabled and vim.bo[bufnr].filetype:match('^Neogit') then + if not neogit_attached and config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then neogit_attached = true vim.schedule(override_neogit_highlights) end @@ -894,7 +897,7 @@ function M.detach_diff() end end ----@return diffs.FugitiveConfig +---@return diffs.FugitiveConfig|false function M.get_fugitive_config() init() return config.fugitive diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 139fb0d..33b93fc 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -328,6 +328,95 @@ describe('diffs', function() end) end) + describe('compute_filetypes', function() + local compute = diffs.compute_filetypes + + it('returns core filetypes with empty config', function() + local fts = compute({}) + assert.are.same({ 'git', 'gitcommit' }, fts) + end) + + it('includes fugitive when fugitive = true', function() + local fts = compute({ fugitive = true }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('includes fugitive when fugitive is a table', function() + local fts = compute({ fugitive = { horizontal = 'dd' } }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('excludes fugitive when fugitive = false', function() + local fts = compute({ fugitive = false }) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) + + it('excludes fugitive when fugitive is nil', function() + local fts = compute({}) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) + + it('includes neogit filetypes when neogit = true', function() + local fts = compute({ neogit = true }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + assert.is_true(vim.tbl_contains(fts, 'NeogitCommitView')) + assert.is_true(vim.tbl_contains(fts, 'NeogitDiffView')) + end) + + it('includes neogit filetypes when neogit is a table', function() + local fts = compute({ neogit = {} }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('excludes neogit when neogit = false', function() + local fts = compute({ neogit = false }) + assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('excludes neogit when neogit is nil', function() + local fts = compute({}) + assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('includes extra_filetypes', function() + local fts = compute({ extra_filetypes = { 'diff' } }) + assert.is_true(vim.tbl_contains(fts, 'diff')) + end) + + it('combines fugitive, neogit, and extra_filetypes', function() + local fts = compute({ fugitive = true, neogit = true, extra_filetypes = { 'diff' } }) + assert.is_true(vim.tbl_contains(fts, 'git')) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + assert.is_true(vim.tbl_contains(fts, 'diff')) + end) + + it('returns custom filetypes when filetypes key is set', function() + local fts = compute({ filetypes = { 'custom' } }) + assert.are.same({ 'custom' }, fts) + end) + + it('backward compat: includes fugitive when enabled = true in table', function() + local fts = compute({ fugitive = { enabled = true } }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('backward compat: excludes fugitive when enabled = false in table', function() + local fts = compute({ fugitive = { enabled = false } }) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) + + it('backward compat: includes neogit when enabled = true in table', function() + local fts = compute({ neogit = { enabled = true } }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('backward compat: excludes neogit when enabled = false in table', function() + local fts = compute({ neogit = { enabled = false } }) + assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) + end) + end) + describe('diff mode', function() local function create_diff_window() vim.cmd('new') From d68cddb1a4b55678be69be04e7bfa169621e376a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:14:27 -0500 Subject: [PATCH 23/55] fix(parser): exclude git diff metadata from neogit filename patterns (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Git diff metadata lines like "new file mode 100644" and "deleted file mode 100644" matched the neogit "new file" and "deleted" filename patterns in the parser, corrupting the current filename and breaking syntax highlighting for subsequent hunks. Closes #120 ## Solution Add negative guards so "new file mode" and "deleted file mode" lines are skipped before the neogit filename capture runs. The guard must evaluate before the capture due to Lua's and/or short-circuit semantics — otherwise the and-operator returns true instead of the captured string. Added 16 parser tests covering all neogit filename patterns, all git diff extended header lines that could collide, and integration scenarios with mixed neogit status + diff metadata buffers. --- lua/diffs/parser.lua | 4 +- spec/parser_spec.lua | 304 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 2 deletions(-) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index fc9eeaa..9d86cd1 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -197,8 +197,8 @@ function M.parse_buffer(bufnr) for i, line in ipairs(lines) do local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') local neogit_file = line:match('^modified%s+(.+)$') - or line:match('^new file%s+(.+)$') - or line:match('^deleted%s+(.+)$') + or (not line:match('^new file mode') and line:match('^new file%s+(.+)$')) + or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$')) or line:match('^renamed%s+(.+)$') or line:match('^copied%s+(.+)$') local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$') diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index f297f58..5613784 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -637,6 +637,310 @@ describe('parser', function() delete_buffer(bufnr) end) + it('detects neogit renamed prefix', function() + local bufnr = create_buffer({ + 'renamed old.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('detects neogit copied prefix', function() + local bufnr = create_buffer({ + 'copied orig.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('orig.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "new file mode" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/src/new.lua b/src/new.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/new.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/new.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "new file mode 100755" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/bin/run b/bin/run', + 'new file mode 100755', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/bin/run', + '@@ -0,0 +1,2 @@', + '+#!/bin/bash', + '+echo hello', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bin/run', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "deleted file mode" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "deleted file mode 100755" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/bin/old b/bin/old', + 'deleted file mode 100755', + 'index abc1234..0000000', + '--- a/bin/old', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-#!/bin/bash', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bin/old', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "old mode" or "new mode" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/script.sh b/script.sh', + 'old mode 100644', + 'new mode 100755', + '@@ -1,1 +1,2 @@', + ' echo hello', + '+echo world', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('script.sh', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "rename from/to" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/old.lua b/new.lua', + 'similarity index 95%', + 'rename from old.lua', + 'rename to new.lua', + '@@ -1,2 +1,2 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('new.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "copy from/to" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/orig.lua b/copy.lua', + 'similarity index 100%', + 'copy from orig.lua', + 'copy to copy.lua', + '@@ -1,1 +1,1 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('copy.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "similarity index" or "dissimilarity index" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/foo.lua b/bar.lua', + 'similarity index 85%', + 'rename from foo.lua', + 'rename to bar.lua', + '@@ -1,2 +1,2 @@', + ' local M = {}', + '-return 1', + '+return 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bar.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "index" line as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/test.lua b/test.lua', + 'index abc1234..def5678 100644', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('neogit new file with diff containing new file mode metadata', function() + local bufnr = create_buffer({ + 'new file src/foo.lua', + 'diff --git a/src/foo.lua b/src/foo.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/foo.lua', + '@@ -0,0 +1,3 @@', + '+local M = {}', + '+M.x = 1', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/foo.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('neogit deleted with diff containing deleted file mode metadata', function() + local bufnr = create_buffer({ + 'deleted src/old.lua', + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('multiple new files with mode metadata do not corrupt filenames', function() + local bufnr = create_buffer({ + 'diff --git a/a.lua b/a.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/a.lua', + '@@ -0,0 +1,1 @@', + '+local a = 1', + 'diff --git a/b.lua b/b.lua', + 'new file mode 100644', + 'index 0000000..def5678', + '--- /dev/null', + '+++ b/b.lua', + '@@ -0,0 +1,1 @@', + '+local b = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.are.equal('a.lua', hunks[1].filename) + assert.are.equal('b.lua', hunks[2].filename) + delete_buffer(bufnr) + end) + + it('fugitive status with new and deleted files containing mode metadata', function() + local bufnr = create_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'A src/new.lua', + 'diff --git a/src/new.lua b/src/new.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/new.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + 'D src/old.lua', + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-local x = 1', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.are.equal('src/new.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal('src/old.lua', hunks[2].filename) + assert.are.equal('lua', hunks[2].ft) + delete_buffer(bufnr) + end) + + it('neogit new file with deep nested path', function() + local bufnr = create_buffer({ + 'new file src/deep/nested/path/module.lua', + '@@ -0,0 +1,1 @@', + '+return {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/deep/nested/path/module.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + it('detects bare filename for untracked files', function() local bufnr = create_buffer({ 'newfile.rs', From b1abfe4f4a164ad776148ca36f852df4f1e4014e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:34:43 -0500 Subject: [PATCH 24/55] feat: remove config deprecation in v0.3.0 (#129) --- doc/diffs.nvim.txt | 6 +---- lua/diffs/init.lua | 57 +++------------------------------------------- spec/init_spec.lua | 25 -------------------- 3 files changed, 4 insertions(+), 84 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 1b816f1..dd87d46 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -36,7 +36,7 @@ etc.) works without vim-fugitive. ============================================================================== SETUP *diffs-setup* -Using lazy.nvim: >lua +Install with lazy.nvim: >lua { 'barrettruth/diffs.nvim', dependencies = { 'tpope/vim-fugitive' }, @@ -621,10 +621,6 @@ line visuals. The overrides are reapplied on `ColorScheme` since Neogit re-defines its groups then. When `neogit = false`, no highlight overrides are applied. -Deprecated: ~ -The `filetypes` config key still works but is deprecated and will be -removed in 0.3.0. Use `fugitive`, `neogit`, and `extra_filetypes` instead. - ============================================================================== API *diffs-api* diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index a293cf9..2423596 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -58,7 +58,6 @@ ---@class diffs.Config ---@field debug boolean|string ---@field hide_prefix boolean ----@field filetypes? string[] @deprecated use fugitive, neogit, extra_filetypes ---@field extra_filetypes string[] ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig|false @@ -193,16 +192,13 @@ end ---@param opts table ---@return string[] function M.compute_filetypes(opts) - if opts.filetypes then - return opts.filetypes - end local fts = { 'git', 'gitcommit' } local fug = opts.fugitive - if fug == true or (type(fug) == 'table' and fug.enabled ~= false) then + if fug == true or type(fug) == 'table' then table.insert(fts, 'fugitive') end local neo = opts.neogit - if neo == true or (type(neo) == 'table' and neo.enabled ~= false) then + if neo == true or type(neo) == 'table' then table.insert(fts, 'NeogitStatus') table.insert(fts, 'NeogitCommitView') table.insert(fts, 'NeogitDiffView') @@ -450,62 +446,15 @@ local function init() local opts = vim.g.diffs or {} - if opts.filetypes then - vim.deprecate( - 'vim.g.diffs.filetypes', - 'fugitive, neogit, and extra_filetypes', - '0.3.0', - 'diffs.nvim' - ) - end - - if not opts.filetypes and opts.fugitive == nil and opts.neogit == nil then - local has_diff_ft = false - if type(opts.extra_filetypes) == 'table' then - for _, ft in ipairs(opts.extra_filetypes) do - if ft == 'diff' then - has_diff_ft = true - break - end - end - end - if not has_diff_ft then - vim.notify( - '[diffs.nvim] fugitive, neogit, and diff filetypes are now opt-in.\n' - .. 'Add the integrations you use to your config:\n\n' - .. ' vim.g.diffs = {\n' - .. ' fugitive = true,\n' - .. ' neogit = true,\n' - .. " extra_filetypes = { 'diff' },\n" - .. ' }\n\n' - .. 'This warning will be removed in 0.3.0.', - vim.log.levels.WARN - ) - end - end - local fugitive_defaults = { horizontal = 'du', vertical = 'dU' } if opts.fugitive == true then opts.fugitive = vim.deepcopy(fugitive_defaults) elseif type(opts.fugitive) == 'table' then - if opts.fugitive.enabled == false then - opts.fugitive = false - else - ---@diagnostic disable-next-line: inject-field - opts.fugitive.enabled = nil - opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults) - end + opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults) end if opts.neogit == true then opts.neogit = {} - elseif type(opts.neogit) == 'table' then - if opts.neogit.enabled == false then - opts.neogit = false - else - ---@diagnostic disable-next-line: inject-field - opts.neogit.enabled = nil - end end vim.validate({ diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 33b93fc..2e1952e 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -390,31 +390,6 @@ describe('diffs', function() assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) assert.is_true(vim.tbl_contains(fts, 'diff')) end) - - it('returns custom filetypes when filetypes key is set', function() - local fts = compute({ filetypes = { 'custom' } }) - assert.are.same({ 'custom' }, fts) - end) - - it('backward compat: includes fugitive when enabled = true in table', function() - local fts = compute({ fugitive = { enabled = true } }) - assert.is_true(vim.tbl_contains(fts, 'fugitive')) - end) - - it('backward compat: excludes fugitive when enabled = false in table', function() - local fts = compute({ fugitive = { enabled = false } }) - assert.is_false(vim.tbl_contains(fts, 'fugitive')) - end) - - it('backward compat: includes neogit when enabled = true in table', function() - local fts = compute({ neogit = { enabled = true } }) - assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) - end) - - it('backward compat: excludes neogit when enabled = false in table', function() - local fts = compute({ neogit = { enabled = false } }) - assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) - end) end) describe('diff mode', function() From dfebc68a1fc3e93dae5b6d49133243ec1886cb19 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 21 Feb 2026 23:02:39 -0500 Subject: [PATCH 25/55] fix(doc): improve q&a format --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0a45156..5d0022f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ luarocks install diffs.nvim ## FAQ -**How do I install with lazy.nvim?** +**Q: How do I install with lazy.nvim?** ```lua { @@ -56,7 +56,7 @@ luarocks install diffs.nvim Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to control loading - `diffs.nvim` lazy-loads itself. -**Does diffs.nvim support vim-fugitive/Neogit?** +**Q: Does diffs.nvim support vim-fugitive/Neogit?** Yes. Enable it in your config: From ebc65d1f8ea4c6f156aa4f52acb7ec508ee8016f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 17:35:22 -0500 Subject: [PATCH 26/55] build(flake): add lua-language-server to devShell Problem: lua-language-server is not available in the dev shell, making it impossible to run local type checks. Solution: add lua-language-server to the devShell packages. --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index db4c54c..0c5f5d8 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,7 @@ pkgs.prettier pkgs.stylua pkgs.selene + pkgs.lua-language-server ]; }; }); From 5946b40491f1dec8da151f24a3adb78ac54b04ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 18:14:05 -0500 Subject: [PATCH 27/55] ci: migrate to nix --- .github/workflows/quality.yaml | 29 +++++++---------------------- .gitignore | 1 + .luarc.json | 2 +- selene.toml | 3 +++ vim.toml | 33 --------------------------------- vim.yaml | 24 ++++++++++++++++++++++++ 6 files changed, 36 insertions(+), 56 deletions(-) delete mode 100644 vim.toml create mode 100644 vim.yaml diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 77049fd..c0b7770 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -25,6 +25,7 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' + - 'vim.yaml' markdown: - '*.md' @@ -35,11 +36,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . lua-lint: name: Lua Lint Check @@ -48,11 +46,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - name: Lint with Selene - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . lua-typecheck: name: Lua Type Check @@ -75,15 +70,5 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Install prettier - run: pnpm add -g prettier@3.1.0 - - name: Check markdown formatting with prettier - run: prettier --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.gitignore b/.gitignore index 13d3d92..b7a91ec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules/ result result-* .direnv/ +.envrc diff --git a/.luarc.json b/.luarc.json index b438cce..23646d3 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,5 +1,5 @@ { - "runtime.version": "Lua 5.1", + "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], diff --git a/selene.toml b/selene.toml index 96cf5ab..f2ada4b 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ std = 'vim' + +[lints] +bad_string_escape = 'allow' diff --git a/vim.toml b/vim.toml deleted file mode 100644 index 3a84ac2..0000000 --- a/vim.toml +++ /dev/null @@ -1,33 +0,0 @@ -[selene] -base = "lua51" -name = "vim" - -[vim] -any = true - -[jit] -any = true - -[bit] -any = true - -[assert] -any = true - -[describe] -any = true - -[it] -any = true - -[before_each] -any = true - -[after_each] -any = true - -[spy] -any = true - -[stub] -any = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..401fce6 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,24 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true From bfd3a40c5f1f337d68c9b9f7400c5c453547d25e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 18:18:30 -0500 Subject: [PATCH 28/55] ci: add bit luajit global --- vim.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vim.yaml b/vim.yaml index 401fce6..3821d25 100644 --- a/vim.yaml +++ b/vim.yaml @@ -22,3 +22,5 @@ globals: any: true stub: any: true + bit: + any: true From 700a9a21adc1654972f6ff1cc4bf665453fe888c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:07:54 -0500 Subject: [PATCH 29/55] fix(conflict)!: change default nav keymaps from ]x/[x to ]c/[c (#132) ## Problem The default conflict navigation keymaps `]x`/`[x` are non-standard. Vim natively uses `]c`/`[c` for diff navigation, so the same keys are far more intuitive for conflict jumping. ## Solution Change the defaults for `conflict.keymaps.next` and `conflict.keymaps.prev` to `]c` and `[c`. This is a breaking change for users relying on the previous defaults without explicit configuration. --- doc/diffs.nvim.txt | 20 ++++++++++---------- lua/diffs/init.lua | 4 ++-- spec/conflict_spec.lua | 4 ++-- spec/merge_spec.lua | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index dd87d46..65345d4 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -95,8 +95,8 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } @@ -368,8 +368,8 @@ Example configuration: >lua vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') vim.keymap.set('n', 'cb', '(diffs-conflict-both)') vim.keymap.set('n', 'cn', '(diffs-conflict-none)') - vim.keymap.set('n', ']x', '(diffs-conflict-next)') - vim.keymap.set('n', '[x', '(diffs-conflict-prev)') + vim.keymap.set('n', ']c', '(diffs-conflict-next)') + vim.keymap.set('n', '[c', '(diffs-conflict-prev)') < *(diffs-merge-ours)* @@ -483,8 +483,8 @@ Configuration: ~ theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } @@ -555,10 +555,10 @@ Configuration: ~ {none} (string|false, default: 'don') Reject both changes (delete entire block). - {next} (string|false, default: ']x') + {next} (string|false, default: ']c') Jump to next conflict marker. Wraps around. - {prev} (string|false, default: '[x') + {prev} (string|false, default: '[c') Jump to previous conflict marker. Wraps around. @@ -582,7 +582,7 @@ When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs theirs (`git show :3:path`) with full treesitter and intra-line highlighting. -The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]x`/`[x`) +The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]c`/`[c`) are available on the diff buffer. They resolve conflicts in the working file by matching diff hunks to conflict markers: @@ -590,7 +590,7 @@ file by matching diff hunks to conflict markers: - `dot` replaces the conflict region with theirs content - `dob` replaces with both (ours then theirs) - `don` removes the conflict region entirely -- `]x`/`[x` navigate between unresolved conflict hunks +- `]c`/`[c` navigate between unresolved conflict hunks Resolved hunks are marked with `(resolved)` virtual text. Hunks that correspond to auto-merged content (no conflict markers) show an diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 2423596..6195e8e 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -152,8 +152,8 @@ local default_config = { theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index dd190c9..9f3df5d 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -12,8 +12,8 @@ local function default_config(overrides) theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, } if overrides then diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 6cb9f7e..5e4b854 100644 --- a/spec/merge_spec.lua +++ b/spec/merge_spec.lua @@ -12,8 +12,8 @@ local function default_config(overrides) theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, } if overrides then From 2feb8a86ed2e0f9b5efabe0817eec556a4d1e28a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:42:59 -0500 Subject: [PATCH 30/55] feat(neogit): use new neogit apis for highlight and repo root (#133) ## Problem diffs.nvim was blanking 18 Neogit highlight groups globally on attach to prevent Neogit's `line_hl_group` fg from stomping treesitter syntax. It also fell back to `getcwd()` plus a subprocess call for repo root detection on Neogit buffers, and had no mechanism to refresh the hunk cache when Neogit lazy-loaded new diff sections. ## Solution Adopts three APIs introduced in NeogitOrg/neogit#1897: - Sets `vim.b.neogit_disable_hunk_highlight = true` on the Neogit buffer at attach time. Neogit's `HunkLine` renderer skips all its own highlight logic when this is set, replacing the need to blank 18 hl groups globally and the associated ColorScheme re-application. - Reads `vim.b.neogit_git_dir` in `get_repo_root()` as a reliable fallback between the existing `b:git_dir` check and the `getcwd()` subprocess path. - Registers a buffer-local `User NeogitDiffLoaded` autocmd on attach that calls `M.refresh()` when Neogit lazy-loads a new diff section, keeping the hunk cache in sync. Closes #128 --- lua/diffs/init.lua | 57 +++++++++++++++++--------------------------- lua/diffs/parser.lua | 5 ++++ 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 6195e8e..8d6dffb 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -410,34 +410,6 @@ local function compute_highlight_groups() end end -local neogit_attached = false - -local neogit_hl_groups = { - 'NeogitDiffAdd', - 'NeogitDiffAddCursor', - 'NeogitDiffAddHighlight', - 'NeogitDiffDelete', - 'NeogitDiffDeleteCursor', - 'NeogitDiffDeleteHighlight', - 'NeogitDiffContext', - 'NeogitDiffContextCursor', - 'NeogitDiffContextHighlight', - 'NeogitDiffHeader', - 'NeogitDiffHeaderHighlight', - 'NeogitHunkHeader', - 'NeogitHunkHeaderCursor', - 'NeogitHunkHeaderHighlight', - 'NeogitHunkMergeHeader', - 'NeogitHunkMergeHeaderCursor', - 'NeogitHunkMergeHeaderHighlight', -} - -local function override_neogit_highlights() - for _, name in ipairs(neogit_hl_groups) do - vim.api.nvim_set_hl(0, name, {}) - end -end - local function init() if initialized then return @@ -661,9 +633,6 @@ local function init() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() - if neogit_attached then - vim.schedule(override_neogit_highlights) - end for bufnr, _ in pairs(attached_buffers) do invalidate_cache(bufnr) end @@ -679,7 +648,6 @@ local function init() ensure_cache(bufnr) local entry = hunk_cache[bufnr] if entry and entry.pending_clear then - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) entry.highlighted = {} entry.pending_clear = false end @@ -705,6 +673,12 @@ local function init() for i = first, last do if not entry.highlighted[i] then local hunk = entry.hunks[i] + local clear_start = hunk.start_line - 1 + local clear_end = clear_start + #hunk.lines + if hunk.header_start_line then + clear_start = hunk.header_start_line - 1 + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts) entry.highlighted[i] = true count = count + 1 @@ -782,9 +756,19 @@ function M.attach(bufnr) end attached_buffers[bufnr] = true - if not neogit_attached and config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then - neogit_attached = true - vim.schedule(override_neogit_highlights) + local neogit_augroup = nil + if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then + vim.b[bufnr].neogit_disable_hunk_highlight = true + neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd('User', { + pattern = 'NeogitDiffLoaded', + group = neogit_augroup, + callback = function() + if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then + M.refresh(bufnr) + end + end, + }) end dbg('attaching to buffer %d', bufnr) @@ -796,6 +780,9 @@ function M.attach(bufnr) callback = function() attached_buffers[bufnr] = nil hunk_cache[bufnr] = nil + if neogit_augroup then + pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup) + end end, }) end diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 9d86cd1..fa1bcc0 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -110,6 +110,11 @@ local function get_repo_root(bufnr) return vim.fn.fnamemodify(git_dir, ':h') end + local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir') + if ok3 and neogit_git_dir then + return vim.fn.fnamemodify(neogit_git_dir, ':h') + end + local cwd = vim.fn.getcwd() local git = require('diffs.git') return git.get_repo_root(cwd .. '/.') From d797833341c4ff3c92ee71b5f025be0bcfbd7665 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:44:56 -0500 Subject: [PATCH 31/55] test: add decoration provider, integration, and neogit spec files (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Regressions #119 and #120 showed the test suite had no coverage of the decoration provider cache pipeline, no end-to-end pipeline tests from buffer content to extmarks, and no Neogit-specific integration tests. ## Solution Adds three new spec files (28 new tests, 316 total): - `spec/decoration_provider_spec.lua` — indirect cache pipeline tests via `_test` table: `ensure_cache` population, content fingerprint guard, `pending_clear` semantics, BufWipeout cleanup - `spec/integration_spec.lua` — full pipeline: diff buffer → `attach` → extmarks; verifies `DiffsAdd`/`DiffsDelete` on correct lines, treesitter captures, multi-hunk coverage - `spec/neogit_integration_spec.lua` — `neogit_disable_hunk_highlight` behavior, NeogitStatus/NeogitDiffView attach and cache population, parser neogit filename patterns Depends on #133. Closes #122 --- spec/decoration_provider_spec.lua | 176 ++++++++++++++++ spec/integration_spec.lua | 330 ++++++++++++++++++++++++++++++ spec/neogit_integration_spec.lua | 126 ++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 spec/decoration_provider_spec.lua create mode 100644 spec/integration_spec.lua create mode 100644 spec/neogit_integration_spec.lua diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua new file mode 100644 index 0000000..f4249db --- /dev/null +++ b/spec/decoration_provider_spec.lua @@ -0,0 +1,176 @@ +require('spec.helpers') +local diffs = require('diffs') + +describe('decoration_provider', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + describe('ensure_cache', function() + it('populates hunk cache for a buffer with diff content', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.is_true(#entry.hunks > 0) + delete_buffer(bufnr) + end) + + it('cache tick matches buffer changedtick after attach', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + local tick = vim.api.nvim_buf_get_changedtick(bufnr) + assert.are.equal(tick, entry.tick) + delete_buffer(bufnr) + end) + + it('re-parses and advances tick when buffer content changes', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs._test.ensure_cache(bufnr) + local tick_after = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after > tick_before) + delete_buffer(bufnr) + end) + + it('skips reparse when fingerprint unchanged but sets pending_clear', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + local original_hunks = entry.hunks + entry.pending_clear = false + + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + entry.line_count = lc + entry.byte_count = bc + entry.tick = -1 + + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) + assert.are.equal(original_hunks, updated.hunks) + assert.are.equal(current_tick, updated.tick) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('does nothing for invalid buffer', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.has_no.errors(function() + diffs._test.ensure_cache(bufnr) + end) + end) + end) + + describe('pending_clear', function() + it('is true after invalidate_cache', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + diffs._test.invalidate_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('is true immediately after fresh ensure_cache', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + end) + + describe('BufWipeout cleanup', function() + it('removes hunk_cache entry after buffer wipeout', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + assert.is_not_nil(diffs._test.hunk_cache[bufnr]) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(diffs._test.hunk_cache[bufnr]) + end) + end) + + describe('multiple hunks in cache', function() + it('stores all parsed hunks for a multi-hunk buffer', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(2, #entry.hunks) + delete_buffer(bufnr) + end) + + it('stores empty hunks table for buffer with no diff content', function() + local bufnr = create_buffer({ + 'Head: main', + 'Help: g?', + '', + 'Nothing to see here', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.same({}, entry.hunks) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua new file mode 100644 index 0000000..7468e8f --- /dev/null +++ b/spec/integration_spec.lua @@ -0,0 +1,330 @@ +require('spec.helpers') +local diffs = require('diffs') +local highlight = require('diffs.highlight') + +local function setup_highlight_groups() + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) + local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a }) + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) +end + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +local function get_diffs_ns() + return vim.api.nvim_get_namespaces()['diffs'] +end + +local function get_extmarks(bufnr, ns) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) +end + +local function highlight_opts_with_background() + return { + hide_prefix = false, + highlights = { + background = true, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + intra = { enabled = false, algorithm = 'default', max_lines = 500 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, + }, + } +end + +describe('integration', function() + before_each(function() + setup_highlight_groups() + end) + + describe('attach and parse', function() + it('attach populates hunk cache for unified diff buffer', function() + local bufnr = create_buffer({ + 'diff --git a/foo.lua b/foo.lua', + 'index abc..def 100644', + '--- a/foo.lua', + '+++ b/foo.lua', + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(1, #entry.hunks) + assert.are.equal('foo.lua', entry.hunks[1].filename) + delete_buffer(bufnr) + end) + + it('attach parses multiple hunks across multiple files', function() + local bufnr = create_buffer({ + 'M foo.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + 'M bar.lua', + '@@ -1,1 +1,2 @@', + ' local a = 1', + '+local b = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(2, #entry.hunks) + delete_buffer(bufnr) + end) + + it('re-attach on same buffer is idempotent', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry_before = diffs._test.hunk_cache[bufnr] + local tick_before = entry_before.tick + diffs.attach(bufnr) + local entry_after = diffs._test.hunk_cache[bufnr] + assert.are.equal(tick_before, entry_after.tick) + delete_buffer(bufnr) + end) + + it('refresh after content change invalidates cache', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs.refresh(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + assert.is_true(tick_before >= 0) + delete_buffer(bufnr) + end) + end) + + describe('extmarks from highlight pipeline', function() + it('DiffsAdd background applied to + lines', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_add') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_diff_add = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsAdd' then + has_diff_add = true + break + end + end + assert.is_true(has_diff_add) + delete_buffer(bufnr) + end) + + it('DiffsDelete background applied to - lines', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + ' local x = 1', + '-local y = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_del') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_diff_delete = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsDelete' then + has_diff_delete = true + break + end + end + assert.is_true(has_diff_delete) + delete_buffer(bufnr) + end) + + it('mixed hunk produces both DiffsAdd and DiffsDelete backgrounds', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_mixed') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_add = false + local has_delete = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsAdd' then + has_add = true + end + if mark[4] and mark[4].hl_group == 'DiffsDelete' then + has_delete = true + end + end + assert.is_true(has_add) + assert.is_true(has_delete) + delete_buffer(bufnr) + end) + + it('no background extmarks for context lines', function() + local bufnr = create_buffer({ + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_ctx') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' local z = 4' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local line_bgs = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then + line_bgs[mark[2]] = d.hl_group + end + end + assert.is_nil(line_bgs[1]) + assert.is_nil(line_bgs[4]) + assert.are.equal('DiffsDelete', line_bgs[2]) + assert.are.equal('DiffsAdd', line_bgs[3]) + delete_buffer(bufnr) + end) + + it('treesitter extmarks applied for lua hunks', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + ' return x', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_ts') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_ts = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + has_ts = true + break + end + end + assert.is_true(has_ts) + delete_buffer(bufnr) + end) + + it('diffs namespace exists after attach', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local ns = get_diffs_ns() + assert.is_not_nil(ns) + assert.is_number(ns) + delete_buffer(bufnr) + end) + end) + + describe('multiple hunks highlighting', function() + it('both hunks in multi-hunk buffer get background extmarks', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 10', + '@@ -10,2 +10,2 @@', + '-local y = 2', + '+local y = 20', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_multi') + local hunk1 = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 10' }, + } + local hunk2 = { + filename = 'test.lua', + lang = 'lua', + start_line = 4, + lines = { '-local y = 2', '+local y = 20' }, + } + highlight.highlight_hunk(bufnr, ns, hunk1, highlight_opts_with_background()) + highlight.highlight_hunk(bufnr, ns, hunk2, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local add_lines = {} + local del_lines = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsAdd' and d.hl_eol then + add_lines[mark[2]] = true + end + if d and d.hl_group == 'DiffsDelete' and d.hl_eol then + del_lines[mark[2]] = true + end + end + assert.is_true(del_lines[1] ~= nil) + assert.is_true(add_lines[2] ~= nil) + assert.is_true(del_lines[4] ~= nil) + assert.is_true(add_lines[5] ~= nil) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/neogit_integration_spec.lua b/spec/neogit_integration_spec.lua new file mode 100644 index 0000000..ef33049 --- /dev/null +++ b/spec/neogit_integration_spec.lua @@ -0,0 +1,126 @@ +require('spec.helpers') + +vim.g.diffs = { neogit = true } + +local diffs = require('diffs') +local parser = require('diffs.parser') + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +describe('neogit_integration', function() + describe('neogit_disable_hunk_highlight', function() + it('sets neogit_disable_hunk_highlight on NeogitStatus buffer after attach', function() + local bufnr = create_buffer({ + 'modified test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_true(vim.b[bufnr].neogit_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + + it('does not set neogit_disable_hunk_highlight on non-Neogit buffer', function() + local bufnr = create_buffer({}) + vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_not_true(vim.b[bufnr].neogit_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + end) + + describe('NeogitStatus buffer attach', function() + it('populates hunk_cache for NeogitStatus buffer with diff content', function() + local bufnr = create_buffer({ + 'modified hello.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + assert.are.equal('hello.lua', entry.hunks[1].filename) + delete_buffer(bufnr) + end) + + it('populates hunk_cache for NeogitDiffView buffer', function() + local bufnr = create_buffer({ + 'new file newmod.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitDiffView', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + delete_buffer(bufnr) + end) + end) + + describe('parser neogit patterns', function() + it('detects renamed prefix via parser', function() + local bufnr = create_buffer({ + 'renamed old.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('old.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects copied prefix via parser', function() + local bufnr = create_buffer({ + 'copied orig.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('orig.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects deleted prefix via parser', function() + local bufnr = create_buffer({ + 'deleted gone.lua', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('gone.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + end) +end) From d45ffd279b9525519a96211a94a797de6ad22af1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:47:25 -0500 Subject: [PATCH 32/55] add back hard-code override (#136) --- lua/diffs/init.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 8d6dffb..0499457 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -410,6 +410,14 @@ local function compute_highlight_groups() end end +local neogit_context_hl_overridden = false + +-- TODO: remove once NeogitOrg/neogit#1904 merges and is released (tracked in #135) +local function override_neogit_context_highlights() + vim.api.nvim_set_hl(0, 'NeogitDiffContextHighlight', {}) + neogit_context_hl_overridden = true +end + local function init() if initialized then return @@ -633,6 +641,9 @@ local function init() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() + if neogit_context_hl_overridden then + override_neogit_context_highlights() + end for bufnr, _ in pairs(attached_buffers) do invalidate_cache(bufnr) end @@ -759,6 +770,7 @@ function M.attach(bufnr) local neogit_augroup = nil if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then vim.b[bufnr].neogit_disable_hunk_highlight = true + override_neogit_context_highlights() neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) vim.api.nvim_create_autocmd('User', { pattern = 'NeogitDiffLoaded', From 6040c054cb5ca33136697421259c7a1bcbefdfe4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:44:57 -0500 Subject: [PATCH 33/55] fix: carry forward highlighted hunks on reparse to reduce flicker (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Toggling large diffs via fugitive's `=` caused the top of the buffer to re-render and glitch. `ensure_cache` always created a new cache entry with `pending_clear=true` and `highlighted={}`, forcing `on_win` to clear and re-highlight every visible hunk — including stable ones above the toggle point that never changed. ## Solution On reparse, compare old and new hunk lists using a prefix + suffix matching strategy. Hunks that match (same filename, line count, and sampled content) carry forward their `highlighted` state so `on_win` skips them. Comparison is O(1) per hunk. Only runs when the old entry had `pending_clear=false`; `invalidate_cache`/`ColorScheme` paths still force full re-highlight. Closes #131 --- lua/diffs/init.lua | 75 ++++++++++++++++- spec/decoration_provider_spec.lua | 128 ++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 0499457..b3f2800 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -222,6 +222,75 @@ local function invalidate_cache(bufnr) end end +---@param a diffs.Hunk +---@param b diffs.Hunk +---@return boolean +local function hunks_eq(a, b) + local n = #a.lines + if n ~= #b.lines or a.filename ~= b.filename then + return false + end + if a.lines[1] ~= b.lines[1] then + return false + end + if n > 1 and a.lines[n] ~= b.lines[n] then + return false + end + if n > 2 then + local mid = math.floor(n / 2) + 1 + if a.lines[mid] ~= b.lines[mid] then + return false + end + end + return true +end + +---@param old_entry diffs.HunkCacheEntry +---@param new_hunks diffs.Hunk[] +---@return table +local function carry_forward_highlighted(old_entry, new_hunks) + local old_hunks = old_entry.hunks + local old_hl = old_entry.highlighted + local old_n = #old_hunks + local new_n = #new_hunks + local highlighted = {} + + local prefix_len = 0 + local limit = math.min(old_n, new_n) + for i = 1, limit do + if not hunks_eq(old_hunks[i], new_hunks[i]) then + break + end + if old_hl[i] then + highlighted[i] = true + end + prefix_len = i + end + + local suffix_len = 0 + local max_suffix = limit - prefix_len + for j = 0, max_suffix - 1 do + local old_idx = old_n - j + local new_idx = new_n - j + if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then + break + end + if old_hl[old_idx] then + highlighted[new_idx] = true + end + suffix_len = j + 1 + end + + dbg( + 'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks', + prefix_len, + suffix_len, + old_n, + new_n + ) + return highlighted +end + ---@param bufnr integer local function ensure_cache(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) then @@ -246,11 +315,12 @@ local function ensure_cache(bufnr) local lc = vim.api.nvim_buf_line_count(bufnr) local bc = vim.api.nvim_buf_get_offset(bufnr, lc) dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks) hunk_cache[bufnr] = { hunks = hunks, tick = tick, - highlighted = {}, - pending_clear = true, + highlighted = carried or {}, + pending_clear = not carried, line_count = lc, byte_count = bc, } @@ -862,6 +932,7 @@ M._test = { hunk_cache = hunk_cache, ensure_cache = ensure_cache, invalidate_cache = invalidate_cache, + hunks_eq = hunks_eq, } return M diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua index f4249db..4b7b2dd 100644 --- a/spec/decoration_provider_spec.lua +++ b/spec/decoration_provider_spec.lua @@ -139,6 +139,134 @@ describe('decoration_provider', function() end) end) + describe('hunk stability', function() + it('carries forward highlighted for stable hunks on section expansion', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_nil(updated.highlighted[2]) + assert.is_true(updated.highlighted[3] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('carries forward highlighted for stable hunks on section collapse', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true, [3] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {}) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_true(updated.highlighted[2] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('bypasses carry-forward when pending_clear was true', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + entry.highlighted = { [1] = true, [2] = true } + entry.pending_clear = true + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.same({}, updated.highlighted) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('does not carry forward when all hunks changed', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + + entry.pending_clear = false + entry.highlighted = { [1] = true } + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'M other.lua', + '@@ -1,1 +1,2 @@', + ' local a = 1', + '+local b = 2', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.is_nil(updated.highlighted[1]) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + end) + describe('multiple hunks in cache', function() it('stores all parsed hunks for a multi-hunk buffer', function() local bufnr = create_buffer({ From 749a21ae3c5c1f419120765357fdcce29374f24a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:20:59 -0500 Subject: [PATCH 34/55] fix: clear stale gutter extmarks after fugitive section toggle (#139) ## Problem Repeatedly toggling `=` in fugitive left green gutter (`number_hl_group`) extmarks on lines between sections. When fugitive collapses a diff section, Neovim compresses extmarks from deleted lines onto the next surviving line (the `M ...` file entry). Two issues prevented cleanup: 1. `carry_forward_highlighted` returned `{}` (truthy in Lua) when zero hunks matched, so `pending_clear` stayed `false` and the compressed extmarks were never cleared. 2. The `nvim_buf_clear_namespace` call in `on_buf`'s `pending_clear` path was removed in 2feb8a8, so even when `pending_clear` was `true` the extmarks survived. ## Solution Return `nil` from `carry_forward_highlighted` when no hunks were carried forward (`next(highlighted) == nil`), so `pending_clear` is correctly set to `true`. Restore `nvim_buf_clear_namespace` in `on_buf`'s `pending_clear` block. Add `process_pending_clear` test helper and spec coverage. --- lua/diffs/init.lua | 16 +++++++++++++++- spec/decoration_provider_spec.lua | 27 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index b3f2800..e64bf40 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -247,7 +247,7 @@ end ---@param old_entry diffs.HunkCacheEntry ---@param new_hunks diffs.Hunk[] ----@return table +---@return table? local function carry_forward_highlighted(old_entry, new_hunks) local old_hunks = old_entry.hunks local old_hl = old_entry.highlighted @@ -288,6 +288,9 @@ local function carry_forward_highlighted(old_entry, new_hunks) old_n, new_n ) + if next(highlighted) == nil then + return nil + end return highlighted end @@ -729,6 +732,7 @@ local function init() ensure_cache(bufnr) local entry = hunk_cache[bufnr] if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) entry.highlighted = {} entry.pending_clear = false end @@ -927,12 +931,22 @@ function M.get_conflict_config() return config.conflict end +local function process_pending_clear(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + entry.highlighted = {} + entry.pending_clear = false + end +end + M._test = { find_visible_hunks = find_visible_hunks, hunk_cache = hunk_cache, ensure_cache = ensure_cache, invalidate_cache = invalidate_cache, hunks_eq = hunks_eq, + process_pending_clear = process_pending_clear, } return M diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua index 4b7b2dd..172ca11 100644 --- a/spec/decoration_provider_spec.lua +++ b/spec/decoration_provider_spec.lua @@ -122,6 +122,31 @@ describe('decoration_provider', function() assert.is_true(entry.pending_clear) delete_buffer(bufnr) end) + + it('clears namespace extmarks when on_buf processes pending_clear', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local ns_id = vim.api.nvim_create_namespace('diffs') + vim.api.nvim_buf_set_extmark(bufnr, ns_id, 0, 0, { line_hl_group = 'DiffAdd' }) + assert.are.equal(1, #vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) + + diffs._test.invalidate_cache(bufnr) + diffs._test.ensure_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + + diffs._test.process_pending_clear(bufnr) + + entry = diffs._test.hunk_cache[bufnr] + assert.is_false(entry.pending_clear) + assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) + delete_buffer(bufnr) + end) end) describe('BufWipeout cleanup', function() @@ -262,7 +287,7 @@ describe('decoration_provider', function() local updated = diffs._test.hunk_cache[bufnr] assert.is_nil(updated.highlighted[1]) - assert.is_false(updated.pending_clear) + assert.is_true(updated.pending_clear) delete_buffer(bufnr) end) end) From 7a3c4ea01e2ad53c6b54136bc19b7f0ad977da7d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:31:15 -0500 Subject: [PATCH 35/55] docs: add table of contents to vimdoc (#146) ## Problem The vimdoc has 16 sections but no table of contents, making it hard to navigate with `:help diffs`. ## Solution Add a numbered `CONTENTS` section with dot-leader formatting and `|tag|` links to each existing section, matching the style used in the project's other plugins. --- doc/diffs.nvim.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 65345d4..3bb5a09 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -22,6 +22,26 @@ Features: ~ - Gutter (line number) highlighting - Inline merge conflict marker detection, highlighting, and resolution +============================================================================== +CONTENTS *diffs-contents* + + 1. Introduction ............................................... |diffs.nvim| + 2. Requirements ....................................... |diffs-requirements| + 3. Setup ..................................................... |diffs-setup| + 4. Configuration ............................................ |diffs-config| + 5. Commands ............................................... |diffs-commands| + 6. Mappings ............................................... |diffs-mappings| + 7. Fugitive Status Keymaps ................................ |diffs-fugitive| + 8. Conflict Resolution .................................... |diffs-conflict| + 9. Merge Diff Resolution ..................................... |diffs-merge| + 10. Neogit ................................................... |diffs-neogit| + 11. API ......................................................... |diffs-api| + 12. Implementation ................................... |diffs-implementation| + 13. Known Limitations ................................... |diffs-limitations| + 14. Highlight Groups ..................................... |diffs-highlights| + 15. Health Check ............................................. |diffs-health| + 16. Acknowledgements ............................... |diffs-acknowledgements| + ============================================================================== REQUIREMENTS *diffs-requirements* From 7106bcc291d5ec77ce848521e15a24297ba84368 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:01:22 -0500 Subject: [PATCH 36/55] refactor(highlight): unified per-line extmark builder (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `highlight_hunk` applied DiffsClear extmarks across 5 scattered sites with ad-hoc column arithmetic. This fragmentation produced the 1-column DiffsClear gap on email-quoted body context lines (#142 issue 1). A redundant `highlight_hunk_vim_syntax` function duplicated the inline vim syntax path, and the deferred pass in init.lua double-called it, creating duplicate scratch buffers and extmarks. ## Solution Reorganize `highlight_hunk` into two clean phases: - **Phase 1** — multi-line syntax computation (treesitter, vim syntax, diff grammar, header context text). Sets syntax extmarks only, no DiffsClear. - **Phase 2** — per-line chrome (DiffsClear, backgrounds, gutter, overlays, intra-line). All non-syntax extmarks consolidated in one pass. Hoist `new_code` to function scope (needed by `highlight_text` outside the `use_ts` block). Hoist `at_raw_line` so Phase 1d and Phase 2b share one `nvim_buf_get_lines` call. Delete `highlight_hunk_vim_syntax` (redundant with inline path). Remove the double-call from the deferred pass in init.lua. Extend body prefix DiffsClear `end_col` from `qw` to `pw + qw`, fixing the 1-column gap where native treesitter background bled through on context lines in email-quoted diffs (#142 issue 1). ### Email-quoted diff support The parser now strips `> ` (and `>> `, etc.) email quote prefixes before pattern matching, enabling syntax highlighting for diffs embedded in email replies and `git-send-email` / sourcehut-style patch review threads. Each hunk stores `quote_width` so the highlight pipeline can apply `DiffsClear` at the correct column offsets to suppress native treesitter on quoted regions. Closes #141 ### #142 status after this PR | Sub-issue | Status | |-----------|--------| | 1. Col gap on context lines | Fixed | | 2. Bare `>` context lines | Improved, edge case remains | | 3. Diff prefix marker fg | Not addressed (follow-up) | --- .luarc.json | 9 +- .styluaignore | 1 + README.md | 2 + flake.nix | 39 ++- lua/diffs/highlight.lua | 157 ++++++---- lua/diffs/init.lua | 25 +- lua/diffs/parser.lua | 24 +- scripts/ci.sh | 10 + spec/highlight_spec.lua | 604 +++++++++++++++++++++++++++++++++++++- spec/integration_spec.lua | 120 +++++++- spec/parser_spec.lua | 30 +- 11 files changed, 919 insertions(+), 102 deletions(-) create mode 100644 .styluaignore create mode 100755 scripts/ci.sh diff --git a/.luarc.json b/.luarc.json index 23646d3..bfbf500 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,14 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ], "workspace.checkThirdParty": false, + "diagnostics.libraryFiles": "Disable", + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/README.md b/README.md index 5d0022f..eee728d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ with language-aware syntax highlighting. - `:Gdiff` unified diff against any revision - Background-only diff colors for `&diff` buffers - Inline merge conflict detection, highlighting, and resolution +- Email-quoted diff highlighting (`> diff ...` prefixes, arbitrary nesting + depth) - Vim syntax fallback, configurable blend/priorities ## Requirements diff --git a/flake.nix b/flake.nix index 0c5f5d8..2221bdf 100644 --- a/flake.nix +++ b/flake.nix @@ -13,24 +13,41 @@ ... }: let - forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { - default = pkgs.mkShell { - packages = [ - (pkgs.luajit.withPackages ( + default = + let + ts-plugin = pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.diff ]); + diff-grammar = pkgs.vimPlugins.nvim-treesitter-parsers.diff; + luaEnv = pkgs.luajit.withPackages ( ps: with ps; [ busted nlua ] - )) - pkgs.prettier - pkgs.stylua - pkgs.selene - pkgs.lua-language-server - ]; - }; + ); + busted-with-grammar = pkgs.writeShellScriptBin "busted" '' + nvim_bin=$(which nvim) + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + printf '#!/bin/sh\nexec "%s" --cmd "set rtp+=${ts-plugin}/runtime" --cmd "set rtp+=${diff-grammar}" "$@"\n' "$nvim_bin" > "$tmpdir/nvim" + chmod +x "$tmpdir/nvim" + PATH="$tmpdir:$PATH" exec ${luaEnv}/bin/busted "$@" + ''; + in + pkgs.mkShell { + packages = [ + busted-with-grammar + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; }); }; } diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 3b50055..806fb0a 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -74,6 +74,7 @@ end ---@param col_offset integer ---@param covered_lines? table ---@param priorities diffs.PrioritiesConfig +---@param force_high_priority? boolean ---@return integer local function highlight_treesitter( bufnr, @@ -83,7 +84,8 @@ local function highlight_treesitter( line_map, col_offset, covered_lines, - priorities + priorities, + force_high_priority ) local code = table.concat(code_lines, '\n') if code == '' then @@ -123,7 +125,9 @@ local function highlight_treesitter( local buf_sc = sc + col_offset local buf_ec = ec + col_offset - local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) + local meta_prio = tonumber(metadata.priority) or 100 + local priority = tree_lang == 'diff' + and ((col_offset > 0 or force_high_priority) and (priorities.syntax + meta_prio - 100) or meta_prio) or priorities.syntax pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { @@ -221,7 +225,7 @@ local function highlight_vim_syntax( pcall(vim.api.nvim_buf_call, scratch, function() vim.cmd('setlocal syntax=' .. ft) - vim.cmd('redraw') + vim.cmd.redraw() ---@param line integer ---@param col integer @@ -292,9 +296,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local covered_lines = {} local extmark_count = 0 + ---@type string[] + local new_code = {} + if use_ts then - ---@type string[] - local new_code = {} ---@type table local new_map = {} ---@type string[] @@ -328,12 +333,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) + 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 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { - end_col = hunk.header_context_col + #hunk.header_context, - hl_group = 'DiffsClear', - priority = p.clear, - }) local header_extmarks = highlight_text( bufnr, ns, @@ -370,7 +369,13 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p, pw > 1) + end + + local at_raw_line + if pw > 1 and opts.highlights.treesitter.enabled then + local at_buf_line = hunk.start_line - 1 + at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1] end ---@type diffs.IntraChanges? @@ -407,6 +412,69 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end + if + pw > 1 + and hunk.header_start_line + and hunk.header_lines + and #hunk.header_lines > 0 + and opts.highlights.treesitter.enabled + then + for i = 0, #hunk.header_lines - 1 do + local buf_line = hunk.header_start_line - 1 + i + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = #hunk.header_lines[i + 1], + hl_group = 'DiffsClear', + priority = p.clear, + }) + + local hline = hunk.header_lines[i + 1] + if hline:match('^index ') then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = 5, + hl_group = '@keyword.diff', + priority = p.syntax, + }) + local dot_pos = hline:find('%.%.', 1, false) + if dot_pos then + local rest = hline:sub(dot_pos + 2) + local hash = rest:match('^(%x+)') + if hash then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1, { + end_col = dot_pos + 1 + #hash, + hl_group = '@constant.diff', + priority = p.syntax, + }) + end + end + end + end + end + + if pw > 1 and at_raw_line then + local at_buf_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { + end_col = #at_raw_line, + hl_group = 'DiffsClear', + priority = p.clear, + }) + if opts.highlights.treesitter.enabled then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { + end_col = #at_raw_line, + hl_group = '@attribute.diff', + priority = p.syntax, + }) + end + end + + if use_ts and hunk.header_context and hunk.header_context_col then + local header_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { + end_col = hunk.header_context_col + #hunk.header_context, + hl_group = 'DiffsClear', + priority = p.clear, + }) + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line @@ -434,6 +502,24 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end + if pw > 1 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = pw, + hl_group = 'DiffsClear', + priority = p.clear, + }) + for ci = 0, pw - 1 do + local ch = line:sub(ci + 1, ci + 1) + if ch == '+' or ch == '-' then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, { + end_col = ci + 1, + hl_group = ch == '+' and '@diff.plus' or '@diff.minus', + priority = p.syntax, + }) + end + end + end + 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, @@ -444,17 +530,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) if opts.highlights.background and is_diff_line then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_row = buf_line + 1, - hl_group = line_hl, - hl_eol = true, + line_hl_group = line_hl, + number_hl_group = opts.highlights.gutter and number_hl or nil, priority = p.line_bg, }) - if opts.highlights.gutter then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - number_hl_group = number_hl, - priority = p.line_bg, - }) - end end if is_marker and line_len > pw then @@ -493,40 +572,4 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end ----@param bufnr integer ----@param ns integer ----@param hunk diffs.Hunk ----@param opts diffs.HunkOpts -function M.highlight_hunk_vim_syntax(bufnr, ns, hunk, opts) - local p = opts.highlights.priorities - local pw = hunk.prefix_width or 1 - - if not hunk.ft or #hunk.lines == 0 then - return - end - - if #hunk.lines > opts.highlights.vim.max_lines then - return - end - - local code_lines = {} - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(pw + 1)) - end - - local covered_lines = {} - highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) - - for buf_line in pairs(covered_lines) do - local line = hunk.lines[buf_line - hunk.start_line + 1] - if line and #line > pw then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { - end_col = #line, - hl_group = 'DiffsClear', - priority = p.clear, - }) - end - end -end - return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index e64bf40..6af7e1a 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -169,6 +169,9 @@ local fast_hl_opts = {} ---@type diffs.HunkOpts ---@type table local attached_buffers = {} +---@type table +local ft_retry_pending = {} + ---@type table local diff_windows = {} @@ -334,12 +337,15 @@ local function ensure_cache(bufnr) has_nil_ft = true end end - if has_nil_ft and vim.fn.did_filetype() ~= 0 then + if has_nil_ft and vim.fn.did_filetype() ~= 0 and not ft_retry_pending[bufnr] then + ft_retry_pending[bufnr] = true vim.schedule(function() if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr) invalidate_cache(bufnr) + vim.cmd('redraw!') end + ft_retry_pending[bufnr] = nil end) end end @@ -411,7 +417,7 @@ local function compute_highlight_groups() local blended_add_text = blend_color(add_fg, bg, alpha) local blended_del_text = blend_color(del_fg, bg, alpha) - vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0, bg = bg }) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) @@ -759,7 +765,7 @@ local function init() if not entry.highlighted[i] then local hunk = entry.hunks[i] local clear_start = hunk.start_line - 1 - local clear_end = clear_start + #hunk.lines + local clear_end = hunk.start_line + #hunk.lines if hunk.header_start_line then clear_start = hunk.header_start_line - 1 end @@ -776,12 +782,18 @@ local function init() end if #deferred_syntax > 0 then local tick = entry.tick + dbg('deferred syntax scheduled: %d hunks tick=%d', #deferred_syntax, tick) vim.schedule(function() if not vim.api.nvim_buf_is_valid(bufnr) then return end local cur = hunk_cache[bufnr] if not cur or cur.tick ~= tick then + dbg( + 'deferred syntax stale: cur.tick=%s captured=%d', + cur and tostring(cur.tick) or 'nil', + tick + ) return end local t1 = config.debug and vim.uv.hrtime() or nil @@ -791,15 +803,12 @@ local function init() } for _, hunk in ipairs(deferred_syntax) do local start_row = hunk.start_line - 1 - local end_row = start_row + #hunk.lines + local end_row = hunk.start_line + #hunk.lines if hunk.header_start_line then start_row = hunk.header_start_line - 1 end vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row) highlight.highlight_hunk(bufnr, ns, hunk, full_opts) - if not hunk.lang and hunk.ft then - highlight.highlight_hunk_vim_syntax(bufnr, ns, hunk, full_opts) - end end if t1 then dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) @@ -866,6 +875,7 @@ function M.attach(bufnr) callback = function() attached_buffers[bufnr] = nil hunk_cache[bufnr] = nil + ft_retry_pending[bufnr] = nil if neogit_augroup then pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup) end @@ -947,6 +957,7 @@ M._test = { invalidate_cache = invalidate_cache, hunks_eq = hunks_eq, process_pending_clear = process_pending_clear, + ft_retry_pending = ft_retry_pending, } return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index fa1bcc0..a32e563 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -60,6 +60,15 @@ local function get_ft_from_filename(filename, repo_root) end local ft = vim.filetype.match({ filename = filename }) + if not ft and vim.fn.did_filetype() ~= 0 then + dbg('retrying filetype match for %s (clearing did_filetype)', filename) + local saved = rawget(vim.fn, 'did_filetype') + rawset(vim.fn, 'did_filetype', function() + return 0 + end) + ft = vim.filetype.match({ filename = filename }) + rawset(vim.fn, 'did_filetype', saved) + end if ft then dbg('filetype from filename: %s', ft) return ft @@ -125,6 +134,7 @@ end function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local repo_root = get_repo_root(bufnr) + ---@type diffs.Hunk[] local hunks = {} @@ -162,7 +172,6 @@ function M.parse_buffer(bufnr) local old_remaining = nil ---@type integer? local new_remaining = nil - local is_unified_diff = false local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -201,6 +210,8 @@ function M.parse_buffer(bufnr) for i, line in ipairs(lines) do local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') + or line:match('^diff %-%-combined (.+)$') + or line:match('^diff %-%-cc (.+)$') local neogit_file = line:match('^modified%s+(.+)$') or (not line:match('^new file mode') and line:match('^new file%s+(.+)$')) or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$')) @@ -209,7 +220,6 @@ function M.parse_buffer(bufnr) local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$') local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file if filename then - is_unified_diff = diff_git_file ~= nil flush_hunk() current_filename = filename local cache_key = (repo_root or '') .. '\0' .. filename @@ -249,10 +259,17 @@ function M.parse_buffer(bufnr) new_remaining = file_new_count end else + local hs, hc = line:match('%-(%d+),?(%d*)') + if hs then + file_old_start = tonumber(hs) + file_old_count = tonumber(hc) or 1 + old_remaining = file_old_count + end local hs2, hc2 = line:match('%+(%d+),?(%d*) @@') if hs2 then file_new_start = tonumber(hs2) file_new_count = tonumber(hc2) or 1 + new_remaining = file_new_count end end local at_end, context = line:match('^(@@+.-@@+%s*)(.*)') @@ -275,13 +292,12 @@ function M.parse_buffer(bufnr) end elseif line == '' - and is_unified_diff and old_remaining and old_remaining > 0 and new_remaining and new_remaining > 0 then - table.insert(hunk_lines, ' ') + table.insert(hunk_lines, string.rep(' ', hunk_prefix_width)) old_remaining = old_remaining - 1 new_remaining = new_remaining - 1 elseif diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..e06bf09 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command busted diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 00a0774..5d4b94e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -287,7 +287,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -320,7 +320,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsDelete' then + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -362,6 +362,122 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('line bg uses line_hl_group not hl_group with end_row', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + 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('DiffsAdd', d and d.hl_group) + assert.is_not_equal('DiffsDelete', d and d.hl_group) + end + delete_buffer(bufnr) + end) + + it('line bg extmark survives adjacent clear_namespace starting at next row', function() + local bufnr = create_buffer({ + 'diff --git a/foo.py b/foo.py', + '@@ -1,2 +1,2 @@', + '-old', + '+new', + }) + + local hunk = { + filename = 'foo.py', + header_start_line = 1, + start_line = 2, + lines = { '-old', '+new' }, + prefix_width = 1, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) + ) + + local last_body_row = hunk.start_line + #hunk.lines - 1 + vim.api.nvim_buf_clear_namespace(bufnr, ns, last_body_row + 1, last_body_row + 10) + + local marks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { last_body_row, 0 }, + { last_body_row, -1 }, + { details = true } + ) + local has_line_bg = false + for _, mark in ipairs(marks) do + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + has_line_bg = true + end + end + assert.is_true(has_line_bg) + delete_buffer(bufnr) + end) + + it('clear range covers last body line of hunk with header', function() + local bufnr = create_buffer({ + 'diff --git a/foo.py b/foo.py', + 'index abc..def 100644', + '--- a/foo.py', + '+++ b/foo.py', + '@@ -1,3 +1,3 @@', + ' ctx', + '-old', + '+new', + }) + + local hunk = { + filename = 'foo.py', + header_start_line = 1, + start_line = 5, + lines = { ' ctx', '-old', '+new' }, + prefix_width = 1, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) + ) + + local last_body_row = hunk.start_line + #hunk.lines - 1 + local clear_start = hunk.header_start_line - 1 + local clear_end = hunk.start_line + #hunk.lines + vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) + + local marks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { last_body_row, 0 }, + { last_body_row, -1 }, + { details = false } + ) + assert.are.equal(0, #marks) + delete_buffer(bufnr) + end) + it('still applies background when treesitter disabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -386,7 +502,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -500,7 +616,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -585,7 +701,7 @@ describe('highlight', function() local found = false for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then found = true end end @@ -741,7 +857,7 @@ describe('highlight', function() if d then if d.hl_group == 'DiffsClear' then table.insert(priorities.clear, d.priority) - elseif (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then + elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then table.insert(priorities.char_bg, d.priority) @@ -842,8 +958,8 @@ describe('highlight', function() local line_bgs = {} for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then - line_bgs[mark[2]] = d.hl_group + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group end end assert.is_nil(line_bgs[1]) @@ -946,11 +1062,16 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) + local content_clear_count = 0 for _, mark in ipairs(extmarks) do if mark[4] and mark[4].hl_group == 'DiffsClear' then - assert.are.equal(2, mark[3]) + assert.is_true(mark[3] == 0 or mark[3] == 2, 'DiffsClear at unexpected col ' .. mark[3]) + if mark[3] == 2 then + content_clear_count = content_clear_count + 1 + end end end + assert.are.equal(2, content_clear_count) delete_buffer(bufnr) end) @@ -1034,8 +1155,8 @@ describe('highlight', function() local marker_text = {} for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then - line_bgs[mark[2]] = d.hl_group + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group end if d and d.number_hl_group then gutter_hls[mark[2]] = d.number_hl_group @@ -1245,6 +1366,467 @@ describe('highlight', function() assert.are.equal(0, header_extmarks) delete_buffer(bufnr) end) + + it('does not apply DiffsClear to header lines for non-quoted diffs', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 5, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then + error('unexpected DiffsClear on header row ' .. mark[2] .. ' for non-quoted diff') + end + end + delete_buffer(bufnr) + end) + + it('preserves diff grammar treesitter on headers for non-quoted diffs', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 4, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_ts_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] < 3 and d and d.hl_group and d.hl_group:match('^@.*%.diff$') then + header_ts_count = header_ts_count + 1 + end + end + assert.is_true(header_ts_count > 0, 'expected diff grammar treesitter on header lines') + delete_buffer(bufnr) + end) + + it('applies syntax extmarks to combined diff body lines', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + ' -local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1', ' -local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local syntax_on_body = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] >= 1 and d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + syntax_on_body = syntax_on_body + 1 + end + end + assert.is_true(syntax_on_body > 0, 'expected lua treesitter syntax on combined diff body') + delete_buffer(bufnr) + end) + + it('applies DiffsClear and per-char diff fg to combined diff body prefixes', function() + local bufnr = create_buffer({ + '@@@', + ' unchanged', + '+ added', + ' -removed', + '++both', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' unchanged', '+ added', ' -removed', '++both' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local prefix_clears = {} + local plus_marks = {} + local minus_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] >= 1 and d then + if d.hl_group == 'DiffsClear' and mark[3] == 0 and d.end_col == 2 then + prefix_clears[mark[2]] = true + end + if d.hl_group == '@diff.plus' and d.priority == 199 then + if not plus_marks[mark[2]] then + plus_marks[mark[2]] = {} + end + table.insert(plus_marks[mark[2]], mark[3]) + end + if d.hl_group == '@diff.minus' and d.priority == 199 then + if not minus_marks[mark[2]] then + minus_marks[mark[2]] = {} + end + table.insert(minus_marks[mark[2]], mark[3]) + end + end + end + + assert.is_true(prefix_clears[1] ~= nil, 'DiffsClear on context prefix') + assert.is_true(prefix_clears[2] ~= nil, 'DiffsClear on add prefix') + assert.is_true(prefix_clears[3] ~= nil, 'DiffsClear on del prefix') + assert.is_true(prefix_clears[4] ~= nil, 'DiffsClear on both-add prefix') + + assert.is_true(plus_marks[2] ~= nil, '@diff.plus on + in "+ added"') + assert.are.equal(0, plus_marks[2][1]) + + assert.is_true(minus_marks[3] ~= nil, '@diff.minus on - in " -removed"') + assert.are.equal(1, minus_marks[3][1]) + + assert.is_true(plus_marks[4] ~= nil, '@diff.plus on ++ in "++both"') + assert.are.equal(2, #plus_marks[4]) + + assert.is_nil(plus_marks[1], 'no @diff.plus on context " unchanged"') + assert.is_nil(minus_marks[1], 'no @diff.minus on context " unchanged"') + delete_buffer(bufnr) + end) + + it('applies DiffsClear to headers for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local clear_lines = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then + clear_lines[mark[2]] = true + end + end + assert.is_true(clear_lines[0] ~= nil, 'DiffsClear on diff --combined line') + assert.is_true(clear_lines[1] ~= nil, 'DiffsClear on index line') + assert.is_true(clear_lines[2] ~= nil, 'DiffsClear on --- line') + assert.is_true(clear_lines[3] ~= nil, 'DiffsClear on +++ line') + delete_buffer(bufnr) + end) + + it('applies @attribute.diff at syntax priority to @@@ line for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_attr = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 0 and d and d.hl_group == '@attribute.diff' and (d.priority or 0) >= 199 then + has_attr = true + end + end + assert.is_true(has_attr, '@attribute.diff at p>=199 on @@@ line') + delete_buffer(bufnr) + end) + + it('applies DiffsClear to @@@ line for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_at_clear = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 0 and d and d.hl_group == 'DiffsClear' and mark[3] == 0 then + has_at_clear = true + end + end + assert.is_true(has_at_clear, 'DiffsClear on @@@ line') + delete_buffer(bufnr) + end) + + it('applies header diff grammar at syntax priority for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local high_prio_diff = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if + mark[2] < 4 + and d + and d.hl_group + and d.hl_group:match('^@.*%.diff$') + and (d.priority or 0) >= 199 + then + high_prio_diff[mark[2]] = true + end + end + assert.is_true(high_prio_diff[2] ~= nil, 'diff grammar at p>=199 on --- line') + assert.is_true(high_prio_diff[3] ~= nil, 'diff grammar at p>=199 on +++ line') + delete_buffer(bufnr) + end) + + it('@diff.minus wins over @punctuation.special on combined diff headers', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local minus_prio, punct_prio_minus = 0, 0 + local plus_prio, punct_prio_plus = 0, 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group then + if mark[2] == 2 then + if d.hl_group == '@diff.minus.diff' then + minus_prio = math.max(minus_prio, d.priority or 0) + elseif d.hl_group == '@punctuation.special.diff' then + punct_prio_minus = math.max(punct_prio_minus, d.priority or 0) + end + elseif mark[2] == 3 then + if d.hl_group == '@diff.plus.diff' then + plus_prio = math.max(plus_prio, d.priority or 0) + elseif d.hl_group == '@punctuation.special.diff' then + punct_prio_plus = math.max(punct_prio_plus, d.priority or 0) + end + end + end + end + assert.is_true( + minus_prio > punct_prio_minus, + '@diff.minus.diff should beat @punctuation.special.diff on --- line' + ) + assert.is_true( + plus_prio > punct_prio_plus, + '@diff.plus.diff should beat @punctuation.special.diff on +++ line' + ) + delete_buffer(bufnr) + end) + + it('applies @keyword.diff on index word for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_keyword = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if + mark[2] == 1 + and d + and d.hl_group == '@keyword.diff' + and mark[3] == 0 + and (d.end_col or 0) == 5 + then + has_keyword = true + end + end + assert.is_true(has_keyword, '@keyword.diff at row 1, cols 0-5') + delete_buffer(bufnr) + end) + + it('applies @constant.diff on result hash for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_constant = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 1 and d and d.hl_group == '@constant.diff' and (d.priority or 0) >= 199 then + has_constant = true + end + end + assert.is_true(has_constant, '@constant.diff on result hash') + delete_buffer(bufnr) + end) end) describe('extmark priority', function() diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua index 7468e8f..23e870b 100644 --- a/spec/integration_spec.lua +++ b/spec/integration_spec.lua @@ -127,6 +127,110 @@ describe('integration', function() end) end) + describe('ft_retry_pending', function() + before_each(function() + rawset(vim.fn, 'did_filetype', function() + return 1 + end) + require('diffs.parser')._test.ft_lang_cache = {} + end) + + after_each(function() + rawset(vim.fn, 'did_filetype', nil) + end) + + it('sets ft_retry_pending when nil-ft hunks detected under did_filetype', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_nil(entry.hunks[1].ft) + assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) + delete_buffer(bufnr) + end) + + it('clears ft_retry_pending after scheduled callback fires', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) + + local done = false + vim.schedule(function() + done = true + end) + vim.wait(1000, function() + return done + end) + + assert.is_nil(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + + it('invalidates cache after scheduled callback fires', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + local tick_after_attach = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after_attach >= 0) + + local done = false + vim.schedule(function() + done = true + end) + vim.wait(1000, function() + return done + end) + + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('does not set ft_retry_pending when did_filetype() is zero', function() + rawset(vim.fn, 'did_filetype', nil) + local bufnr = create_buffer({ + 'diff --git a/test.sh b/test.sh', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env bash', + '-old line', + '+new line', + }) + diffs.attach(bufnr) + assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + + it('does not set ft_retry_pending for files with resolvable ft', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + end) + describe('extmarks from highlight pipeline', function() it('DiffsAdd background applied to + lines', function() local bufnr = create_buffer({ @@ -145,7 +249,7 @@ describe('integration', function() local extmarks = get_extmarks(bufnr, ns) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -171,7 +275,7 @@ describe('integration', function() local extmarks = get_extmarks(bufnr, ns) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsDelete' then + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -198,10 +302,10 @@ describe('integration', function() local has_add = false local has_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_add = true end - if mark[4] and mark[4].hl_group == 'DiffsDelete' then + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then has_delete = true end end @@ -230,8 +334,8 @@ describe('integration', function() local line_bgs = {} for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then - line_bgs[mark[2]] = d.hl_group + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group end end assert.is_nil(line_bgs[1]) @@ -313,10 +417,10 @@ describe('integration', function() local del_lines = {} for _, mark in ipairs(extmarks) do local d = mark[4] - if d and d.hl_group == 'DiffsAdd' and d.hl_eol then + if d and d.line_hl_group == 'DiffsAdd' then add_lines[mark[2]] = true end - if d and d.hl_group == 'DiffsDelete' and d.hl_eol then + if d and d.line_hl_group == 'DiffsDelete' then del_lines[mark[2]] = true end end diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 5613784..adbbd37 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -163,10 +163,10 @@ describe('parser', function() end end) - it('stops hunk at blank line', function() + it('stops hunk at blank line when remaining counts exhausted', function() local bufnr = create_buffer({ 'M test.lua', - '@@ -1,2 +1,3 @@', + '@@ -1,1 +1,2 @@', ' local x = 1', '+local y = 2', '', @@ -391,6 +391,29 @@ describe('parser', function() vim.fn.delete(repo_root, 'rf') end) + it('detects filetype for .sh files when did_filetype() is non-zero', function() + rawset(vim.fn, 'did_filetype', function() + return 1 + end) + + parser._test.ft_lang_cache = {} + local bufnr = create_buffer({ + 'diff --git a/test.sh b/test.sh', + '@@ -1,3 +1,4 @@', + ' #!/usr/bin/env bash', + ' set -euo pipefail', + '-echo "running tests..."', + '+echo "running tests with coverage..."', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('test.sh', hunks[1].filename) + assert.are.equal('sh', hunks[1].ft) + delete_buffer(bufnr) + rawset(vim.fn, 'did_filetype', nil) + end) + it('extracts file line numbers from @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -506,7 +529,8 @@ describe('parser', function() 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) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(3, hunks[1].file_old_count) delete_buffer(bufnr) end) From 90b312e8df26d3c628aeb61c1b6a40219d7cac5d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:16:04 -0500 Subject: [PATCH 37/55] fix(highlight): prevent duplicate extmarks from two-pass rendering (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Two-pass rendering (Pass 1: backgrounds + intra-line; Pass 2: treesitter) caused Pass 2 to re-apply all extmarks that Pass 1 already set, doubling the extmark count on affected lines. ## Solution Add `syntax_only` mode to `highlight_hunk`. When `syntax_only = true`, only treesitter syntax and content `DiffsClear` extmarks are applied — backgrounds, intra-line, prefix clears, and per-char prefix highlights are skipped. Pass 2 now uses `syntax_only = true` and no longer calls `nvim_buf_clear_namespace`, so Pass 1's extmarks persist while Pass 2 layers syntax on top. Closes #143 --- lua/diffs/highlight.lua | 131 +++++++++++++++++++++------------------- lua/diffs/init.lua | 11 +--- spec/highlight_spec.lua | 117 +++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 69 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 806fb0a..836a9f4 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -65,6 +65,7 @@ end ---@field hide_prefix boolean ---@field highlights diffs.Highlights ---@field defer_vim_syntax? boolean +---@field syntax_only? boolean ---@param bufnr integer ---@param ns integer @@ -381,7 +382,13 @@ 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 pw == 1 and #hunk.lines <= intra_cfg.max_lines then + if + not opts.syntax_only + and 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 @@ -494,28 +501,70 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 = { { string.rep(' ', pw), virt_hl } }, - virt_text_pos = 'overlay', - }) - end + if not opts.syntax_only then + 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 = { { string.rep(' ', pw), virt_hl } }, + virt_text_pos = 'overlay', + }) + end - if pw > 1 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = pw, - hl_group = 'DiffsClear', - priority = p.clear, - }) - for ci = 0, pw - 1 do - local ch = line:sub(ci + 1, ci + 1) - if ch == '+' or ch == '-' then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, { - end_col = ci + 1, - hl_group = ch == '+' and '@diff.plus' or '@diff.minus', - priority = p.syntax, + if pw > 1 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = pw, + hl_group = 'DiffsClear', + priority = p.clear, + }) + for ci = 0, pw - 1 do + local ch = line:sub(ci + 1, ci + 1) + if ch == '+' or ch == '-' then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, { + end_col = ci + 1, + hl_group = ch == '+' and '@diff.plus' or '@diff.minus', + priority = p.syntax, + }) + end + end + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + line_hl_group = line_hl, + number_hl_group = opts.highlights.gutter and number_hl or nil, + priority = p.line_bg, + }) + 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 = 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"', + i, + buf_line, + span.col_start, + span.col_end, + char_hl, + line:sub(span.col_start + 1, span.col_end) + ) + local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = char_hl, + priority = p.char_bg, }) + if not ok then + dbg('char extmark FAILED: %s', err) + end + extmark_count = extmark_count + 1 end end end @@ -527,46 +576,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) priority = p.clear, }) end - - if opts.highlights.background and is_diff_line then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - line_hl_group = line_hl, - number_hl_group = opts.highlights.gutter and number_hl or nil, - priority = p.line_bg, - }) - 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 = 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"', - i, - buf_line, - span.col_start, - span.col_end, - char_hl, - line:sub(span.col_start + 1, span.col_end) - ) - local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { - end_col = span.col_end, - hl_group = char_hl, - priority = p.char_bg, - }) - if not ok then - dbg('char extmark FAILED: %s', err) - end - extmark_count = extmark_count + 1 - end - end end dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 6af7e1a..242c247 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -797,18 +797,13 @@ local function init() return end local t1 = config.debug and vim.uv.hrtime() or nil - local full_opts = { + local syntax_opts = { hide_prefix = config.hide_prefix, highlights = config.highlights, + syntax_only = true, } for _, hunk in ipairs(deferred_syntax) do - local start_row = hunk.start_line - 1 - local end_row = hunk.start_line + #hunk.lines - if hunk.header_start_line then - start_row = hunk.header_start_line - 1 - end - vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row) - highlight.highlight_hunk(bufnr, ns, hunk, full_opts) + highlight.highlight_hunk(bufnr, ns, hunk, syntax_opts) end if t1 then dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 5d4b94e..39cc23c 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1249,6 +1249,123 @@ describe('highlight', function() end delete_buffer(bufnr) end) + + it('two-pass rendering produces no duplicate extmarks', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { fg = 0x80c080, bg = 0x2e4a3a }) + vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { fg = 0xc08080, bg = 0x4a2e3a }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + local fast = default_opts({ + highlights = { + treesitter = { enabled = false }, + background = true, + gutter = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + + local syntax = default_opts({ + highlights = { + treesitter = { enabled = true }, + background = true, + gutter = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + syntax.syntax_only = true + + highlight.highlight_hunk(bufnr, ns, hunk, fast) + highlight.highlight_hunk(bufnr, ns, hunk, syntax) + + local extmarks = get_extmarks(bufnr) + for row = 1, 2 do + local line_hl_count = 0 + local number_hl_count = 0 + local intra_count = 0 + for _, mark in ipairs(extmarks) do + if mark[2] == row then + local d = mark[4] + if d.line_hl_group then + line_hl_count = line_hl_count + 1 + end + if d.number_hl_group then + number_hl_count = number_hl_count + 1 + end + if d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then + intra_count = intra_count + 1 + end + end + end + assert.are.equal(1, line_hl_count, 'row ' .. row .. ' has duplicate line_hl_group') + assert.are.equal(1, number_hl_count, 'row ' .. row .. ' has duplicate number_hl_group') + assert.is_true(intra_count <= 1, 'row ' .. row .. ' has duplicate intra extmarks') + end + delete_buffer(bufnr) + end) + + it('syntax_only pass adds treesitter without duplicating backgrounds', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + + local fast = default_opts({ + highlights = { + treesitter = { enabled = false }, + background = true, + }, + }) + + local syntax = default_opts({ + highlights = { + treesitter = { enabled = true }, + background = true, + }, + }) + syntax.syntax_only = true + + highlight.highlight_hunk(bufnr, ns, hunk, fast) + highlight.highlight_hunk(bufnr, ns, hunk, syntax) + + local extmarks = get_extmarks(bufnr) + local has_ts = false + local line_hl_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + has_ts = true + end + if d and d.line_hl_group then + line_hl_count = line_hl_count + 1 + end + end + assert.is_true(has_ts) + assert.are.equal(1, line_hl_count) + delete_buffer(bufnr) + end) end) describe('diff header highlighting', function() From 70d5bee79767f4dd5d0c7259b4e219c6cb9f3368 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:27:48 -0500 Subject: [PATCH 38/55] fix(init): remove NeogitDiffContextHighlight override workaround (#147) ## Problem diffs.nvim blanked `NeogitDiffContextHighlight` globally on attach and on `ColorScheme` to work around Neogit's `ViewContext` decoration provider overriding `DiffsAdd`/`DiffsDelete` line backgrounds with `NeogitDiffContextHighlight` at priority 200. ## Solution Remove the `override_neogit_context_highlights` workaround. NeogitOrg/neogit#1907 moves the `neogit_disable_hunk_highlight` check inside ViewContext's per-line loop, so non-cursor lines skip `add_line_highlight` entirely. `vim.b[bufnr].neogit_disable_hunk_highlight = true` (set on attach) is sufficient. Closes #135 --- lua/diffs/init.lua | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 242c247..d1cc7d2 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -489,14 +489,6 @@ local function compute_highlight_groups() end end -local neogit_context_hl_overridden = false - --- TODO: remove once NeogitOrg/neogit#1904 merges and is released (tracked in #135) -local function override_neogit_context_highlights() - vim.api.nvim_set_hl(0, 'NeogitDiffContextHighlight', {}) - neogit_context_hl_overridden = true -end - local function init() if initialized then return @@ -720,9 +712,6 @@ local function init() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() - if neogit_context_hl_overridden then - override_neogit_context_highlights() - end for bufnr, _ in pairs(attached_buffers) do invalidate_cache(bufnr) end @@ -848,7 +837,6 @@ function M.attach(bufnr) local neogit_augroup = nil if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then vim.b[bufnr].neogit_disable_hunk_highlight = true - override_neogit_context_highlights() neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) vim.api.nvim_create_autocmd('User', { pattern = 'NeogitDiffLoaded', From e1d3b81607e5059c14e54ab788114011cc89a402 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:31:19 -0500 Subject: [PATCH 39/55] feat: support email-quoted diffs (#149) ## Problem Email-quoted diffs (`> diff --git ...`, `> @@ ...`) from git-send-email / email reply workflows produce 0 hunks because the parser matches patterns against raw lines containing `> ` quote prefixes. Closes #141. ## Solution Strip the `> ` quote prefix before pattern matching in the parser. Store `quote_width` on each hunk. In `highlight.lua`, offset all extmark column positions by `qw` and expand `pw > 1` guards to `qw > 0 or pw > 1` for DiffsClear suppression. Clamp body prefix DiffsClear `end_col` to the actual buffer line byte length for bare `>` lines (1-byte buffer lines where `end_col = pw + qw` would exceed bounds and cause `nvim_buf_set_extmark` to silently fail inside `pcall`). 15 new specs covering parser detection, stripping, false-positive rejection, and highlight column offsets including the bare `>` clamp edge case. --- lua/diffs/highlight.lua | 109 +++++---- lua/diffs/parser.lua | 79 ++++--- spec/email_quote_spec.lua | 477 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+), 66 deletions(-) create mode 100644 spec/email_quote_spec.lua diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 836a9f4..3190641 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -245,7 +245,7 @@ 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 col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1 local extmark_count = 0 for _, span in ipairs(spans) do local adj = span.line - leading_offset @@ -273,6 +273,7 @@ end function M.highlight_hunk(bufnr, ns, hunk, opts) local p = opts.highlights.priorities local pw = hunk.prefix_width or 1 + local qw = hunk.quote_width or 0 local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled @@ -329,9 +330,9 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end extmark_count = - highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p) + highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw + qw, covered_lines, p) extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p) + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw + qw, covered_lines, p) if hunk.header_context and hunk.header_context_col then local header_extmarks = highlight_text( @@ -370,11 +371,21 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p, pw > 1) + + highlight_treesitter( + bufnr, + ns, + hunk.header_lines, + 'diff', + header_map, + qw, + nil, + p, + qw > 0 or pw > 1 + ) end local at_raw_line - if pw > 1 and opts.highlights.treesitter.enabled then + if (qw > 0 or pw > 1) and opts.highlights.treesitter.enabled then local at_buf_line = hunk.start_line - 1 at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1] end @@ -420,7 +431,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if - pw > 1 + (qw > 0 or pw > 1) and hunk.header_start_line and hunk.header_lines and #hunk.header_lines > 0 @@ -429,35 +440,37 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) for i = 0, #hunk.header_lines - 1 do local buf_line = hunk.header_start_line - 1 + i pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = #hunk.header_lines[i + 1], + end_col = #hunk.header_lines[i + 1] + qw, hl_group = 'DiffsClear', priority = p.clear, }) - local hline = hunk.header_lines[i + 1] - if hline:match('^index ') then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = 5, - hl_group = '@keyword.diff', - priority = p.syntax, - }) - local dot_pos = hline:find('%.%.', 1, false) - if dot_pos then - local rest = hline:sub(dot_pos + 2) - local hash = rest:match('^(%x+)') - if hash then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1, { - end_col = dot_pos + 1 + #hash, - hl_group = '@constant.diff', - priority = p.syntax, - }) + if pw > 1 then + local hline = hunk.header_lines[i + 1] + if hline:match('^index ') then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, { + end_col = 5 + qw, + hl_group = '@keyword.diff', + priority = p.syntax, + }) + local dot_pos = hline:find('%.%.', 1, false) + if dot_pos then + local rest = hline:sub(dot_pos + 2) + local hash = rest:match('^(%x+)') + if hash then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1 + qw, { + end_col = dot_pos + 1 + #hash + qw, + hl_group = '@constant.diff', + priority = p.syntax, + }) + end end end end end end - if pw > 1 and at_raw_line then + if (qw > 0 or pw > 1) and at_raw_line then local at_buf_line = hunk.start_line - 1 pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { end_col = #at_raw_line, @@ -465,7 +478,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) priority = p.clear, }) if opts.highlights.treesitter.enabled then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, qw, { end_col = #at_raw_line, hl_group = '@attribute.diff', priority = p.syntax, @@ -482,9 +495,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end + local raw_body_lines + if qw > 0 then + raw_body_lines = + vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false) + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line + local raw_len = raw_body_lines and #raw_body_lines[i] or nil local prefix = line:sub(1, pw) local has_add = prefix:find('+', 1, true) ~= nil local has_del = prefix:find('-', 1, true) ~= nil @@ -505,22 +525,30 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 = { { string.rep(' ', pw), virt_hl } }, + virt_text = { { string.rep(' ', pw + qw), virt_hl } }, virt_text_pos = 'overlay', }) end - if pw > 1 then + if qw > 0 or pw > 1 then + local prefix_end = pw + qw + if raw_len and prefix_end > raw_len then + prefix_end = raw_len + end pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = pw, + end_col = prefix_end, hl_group = 'DiffsClear', priority = p.clear, }) for ci = 0, pw - 1 do local ch = line:sub(ci + 1, ci + 1) if ch == '+' or ch == '-' then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, { - end_col = ci + 1, + local char_col = ci + qw + if raw_len and char_col >= raw_len then + break + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, { + end_col = char_col + 1, hl_group = ch == '+' and '@diff.plus' or '@diff.minus', priority = p.syntax, }) @@ -537,8 +565,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, hl_group = 'DiffsConflictMarker', priority = p.char_bg, }) @@ -556,11 +584,12 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) char_hl, line:sub(span.col_start + 1, span.col_end) ) - local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { - end_col = span.col_end, - hl_group = char_hl, - priority = p.char_bg, - }) + local ok, err = + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + qw, { + end_col = span.col_end + qw, + hl_group = char_hl, + priority = p.char_bg, + }) if not ok then dbg('char extmark FAILED: %s', err) end @@ -570,8 +599,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end 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, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, hl_group = 'DiffsClear', priority = p.clear, }) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index a32e563..3df3d4c 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -13,6 +13,7 @@ ---@field file_new_start integer? ---@field file_new_count integer? ---@field prefix_width integer +---@field quote_width integer ---@field repo_root string? local M = {} @@ -135,6 +136,17 @@ function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local repo_root = get_repo_root(bufnr) + local quote_prefix = nil + local quote_width = 0 + for _, l in ipairs(lines) do + local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-') + if qp then + quote_prefix = qp + quote_width = #qp + break + end + end + ---@type diffs.Hunk[] local hunks = {} @@ -172,6 +184,7 @@ function M.parse_buffer(bufnr) local old_remaining = nil ---@type integer? local new_remaining = nil + local current_quote_width = 0 local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -184,6 +197,7 @@ function M.parse_buffer(bufnr) header_context_col = hunk_header_context_col, lines = hunk_lines, prefix_width = hunk_prefix_width, + quote_width = current_quote_width, file_old_start = file_old_start, file_old_count = file_old_count, file_new_start = file_new_start, @@ -209,19 +223,32 @@ function M.parse_buffer(bufnr) end for i, line in ipairs(lines) do - local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') - or line:match('^diff %-%-combined (.+)$') - or line:match('^diff %-%-cc (.+)$') - local neogit_file = line:match('^modified%s+(.+)$') - or (not line:match('^new file mode') and line:match('^new file%s+(.+)$')) - or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$')) - or line:match('^renamed%s+(.+)$') - or line:match('^copied%s+(.+)$') - local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$') - local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file + local logical = line + if quote_prefix then + if line:sub(1, quote_width) == quote_prefix then + logical = line:sub(quote_width + 1) + elseif line:match('^>+$') then + logical = '' + end + end + + local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$') + or logical:match('^diff %-%-combined (.+)$') + or logical:match('^diff %-%-cc (.+)$') + local neogit_file = logical:match('^modified%s+(.+)$') + or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$')) + or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$')) + or logical:match('^renamed%s+(.+)$') + or logical:match('^copied%s+(.+)$') + local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$') + local filename = logical:match('^[MADRCU%?!]%s+(.+)$') + or diff_git_file + or neogit_file + or bare_file if filename then flush_hunk() current_filename = filename + current_quote_width = (logical ~= line) and quote_width or 0 local cache_key = (repo_root or '') .. '\0' .. filename local cached = ft_lang_cache[cache_key] if cached then @@ -243,13 +270,13 @@ function M.parse_buffer(bufnr) hunk_prefix_width = 1 header_start = i header_lines = {} - elseif line:match('^@@+') then + elseif logical:match('^@@+') then flush_hunk() hunk_start = i - local at_prefix = line:match('^(@@+)') + local at_prefix = logical:match('^(@@+)') hunk_prefix_width = #at_prefix - 1 if #at_prefix == 2 then - local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') + local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') if hs then file_old_start = tonumber(hs) file_old_count = tonumber(hc) or 1 @@ -259,31 +286,31 @@ function M.parse_buffer(bufnr) new_remaining = file_new_count end else - local hs, hc = line:match('%-(%d+),?(%d*)') + local hs, hc = logical:match('%-(%d+),?(%d*)') if hs then file_old_start = tonumber(hs) file_old_count = tonumber(hc) or 1 old_remaining = file_old_count end - local hs2, hc2 = line:match('%+(%d+),?(%d*) @@') + local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@') if hs2 then file_new_start = tonumber(hs2) file_new_count = tonumber(hc2) or 1 new_remaining = file_new_count end end - local at_end, context = line:match('^(@@+.-@@+%s*)(.*)') + local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)') if context and context ~= '' then hunk_header_context = context - hunk_header_context_col = #at_end + hunk_header_context_col = #at_end + current_quote_width end if hunk_count then hunk_count = hunk_count + 1 end elseif hunk_start then - local prefix = line:sub(1, 1) + local prefix = logical:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then - table.insert(hunk_lines, line) + table.insert(hunk_lines, logical) if old_remaining and (prefix == ' ' or prefix == '-') then old_remaining = old_remaining - 1 end @@ -291,7 +318,7 @@ function M.parse_buffer(bufnr) new_remaining = new_remaining - 1 end elseif - line == '' + logical == '' and old_remaining and old_remaining > 0 and new_remaining @@ -301,11 +328,11 @@ function M.parse_buffer(bufnr) old_remaining = old_remaining - 1 new_remaining = new_remaining - 1 elseif - line == '' - or line:match('^[MADRC%?!]%s+') - or line:match('^diff ') - or line:match('^index ') - or line:match('^Binary ') + logical == '' + or logical:match('^[MADRC%?!]%s+') + or logical:match('^diff ') + or logical:match('^index ') + or logical:match('^Binary ') then flush_hunk() current_filename = nil @@ -315,7 +342,7 @@ function M.parse_buffer(bufnr) end end if header_start and not hunk_start then - table.insert(header_lines, line) + table.insert(header_lines, logical) end end diff --git a/spec/email_quote_spec.lua b/spec/email_quote_spec.lua new file mode 100644 index 0000000..e1744a1 --- /dev/null +++ b/spec/email_quote_spec.lua @@ -0,0 +1,477 @@ +require('spec.helpers') +local highlight = require('diffs.highlight') +local parser = require('diffs.parser') + +describe('email-quoted diffs', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + describe('parser', function() + it('parses a fully email-quoted unified diff', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> index abc1234..def5678 100644', + '> --- a/foo.py', + '> +++ b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('+from typing import Annotated, final', hunks[1].lines[1]) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('parses a quoted diff embedded in an email reply', function() + local bufnr = create_buffer({ + 'Looks good, one nit:', + '', + '> diff --git a/foo.py b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + '', + 'Maybe rename Foo to Bar?', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('sets quote_width = 0 on normal (unquoted) diffs', function() + local bufnr = create_buffer({ + 'diff --git a/bar.lua b/bar.lua', + '@@ -1,2 +1,2 @@', + '-old_line', + '+new_line', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(0, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('treats bare > lines as empty quoted lines', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('-old', hunks[1].lines[1]) + assert.are.equal(' ', hunks[1].lines[2]) + assert.are.equal('+new', hunks[1].lines[3]) + delete_buffer(bufnr) + end) + + it('handles deeply nested quotes', function() + local bufnr = create_buffer({ + '>> diff --git a/foo.py b/foo.py', + '>> @@ -0,0 +1,2 @@', + '>> +line1', + '>> +line2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].quote_width) + assert.are.equal('+line1', hunks[1].lines[1]) + delete_buffer(bufnr) + end) + + it('adjusts header_context_col for quote width', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,2 +1,2 @@ def hello():', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('def hello():', hunks[1].header_context) + assert.are.equal(#'@@ -1,2 +1,2 @@ ' + 2, hunks[1].header_context_col) + delete_buffer(bufnr) + end) + + it('does not false-positive on prose containing > diff', function() + local bufnr = create_buffer({ + '> diff between approaches is small', + '> I think we should go with option A', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(0, #hunks) + delete_buffer(bufnr) + end) + + it('stores header lines stripped of quote prefix', function() + local bufnr = create_buffer({ + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.is_not_nil(hunks[1].header_lines) + for _, hline in ipairs(hunks[1].header_lines) do + assert.is_nil(hline:match('^> ')) + end + delete_buffer(bufnr) + end) + end) + + describe('highlight', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_email_test') + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e1e }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x1a3a1a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x3a1a1a }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) + end) + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts(overrides) + local opts = { + hide_prefix = false, + highlights = { + background = true, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, + intra = { + enabled = false, + algorithm = 'default', + max_lines = 500, + }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, + }, + } + if overrides then + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + for k, v in pairs(overrides) do + if k ~= 'highlights' then + opts[k] = v + end + end + end + return opts + end + + it('applies DiffsClear on email-quoted header lines covering full buffer width', function() + local buf_lines = { + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + } + local bufnr = create_buffer(buf_lines) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 5, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + header_start_line = 1, + header_lines = { + 'diff --git a/foo.lua b/foo.lua', + 'index abc1234..def5678 100644', + '--- a/foo.lua', + '+++ b/foo.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] < 4 then + table.insert(header_clears, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + assert.is_true(#header_clears > 0) + for _, c in ipairs(header_clears) do + assert.are.equal(0, c.col) + local buf_line_len = #buf_lines[c.row + 1] + assert.are.equal(buf_line_len, c.end_col) + end + + delete_buffer(bufnr) + end) + + it('applies body prefix DiffsClear covering [0, pw+qw)', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local prefix_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and d.end_col == 3 and mark[3] == 0 then + table.insert(prefix_clears, { row = mark[2] }) + end + end + assert.are.equal(2, #prefix_clears) + + delete_buffer(bufnr) + end) + + it('clamps body prefix DiffsClear on bare > lines (1-byte buffer line)', function() + local bufnr = create_buffer({ + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + ft = 'lua', + lang = 'lua', + start_line = 1, + lines = { '-old', ' ', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local bare_line_row = 2 + local bare_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] == bare_line_row and mark[3] == 0 then + table.insert(bare_clears, { end_col = d.end_col }) + end + end + assert.are.equal(1, #bare_clears) + assert.are.equal(1, bare_clears[1].end_col) + + delete_buffer(bufnr) + end) + + it('applies per-char @diff.plus/@diff.minus at ci + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local diff_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == '@diff.plus' or d.hl_group == '@diff.minus') then + table.insert( + diff_marks, + { row = mark[2], col = mark[3], end_col = d.end_col, hl = d.hl_group } + ) + end + end + assert.is_true(#diff_marks >= 2) + for _, dm in ipairs(diff_marks) do + assert.are.equal(2, dm.col) + assert.are.equal(3, dm.end_col) + end + + delete_buffer(bufnr) + end) + + it('offsets treesitter extmarks by pw + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local ts_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + table.insert(ts_marks, { row = mark[2], col = mark[3] }) + end + end + assert.is_true(#ts_marks > 0) + for _, tm in ipairs(ts_marks) do + assert.is_true(tm.col >= 3) + end + + delete_buffer(bufnr) + end) + + it('offsets intra-line char span extmarks by qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -hello world', + '> +hello earth', + }) + + local hunk = { + filename = 'test.txt', + ft = nil, + lang = nil, + start_line = 1, + lines = { '-hello world', '+hello earth' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local char_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + table.insert(char_marks, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + if #char_marks > 0 then + for _, cm in ipairs(char_marks) do + assert.is_true(cm.col >= 2) + end + end + + delete_buffer(bufnr) + end) + + it('does not produce duplicate extmarks with syntax_only + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ syntax_only = true })) + + local extmarks = get_extmarks(bufnr) + local line_bg_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.line_hl_group == 'DiffsAdd' then + line_bg_count = line_bg_count + 1 + end + end + assert.are.equal(1, line_bg_count) + + delete_buffer(bufnr) + end) + end) +end) From 29e624d9f053524f75c15c792261d99990093f93 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:13:28 -0500 Subject: [PATCH 40/55] feat: enable vim syntax fallback by default (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Languages without a treesitter parser (COBOL, Fortran, etc.) got no syntax highlighting because \`highlights.vim.enabled\` defaulted to \`false\`. ## Solution Flip the default to \`true\`. The vim syntax path is already deferred via \`vim.schedule\` so it never blocks the first paint. \`max_lines = 200\` stays unchanged — appropriate given the ~30x slower per-hunk cost vs treesitter. --- README.md | 8 ++++---- doc/diffs.nvim.txt | 12 +++++++----- lua/diffs/init.lua | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index eee728d..2a7bd73 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ with language-aware syntax highlighting. [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for word-level accuracy) - `:Gdiff` unified diff against any revision -- Background-only diff colors for `&diff` buffers - Inline merge conflict detection, highlighting, and resolution -- Email-quoted diff highlighting (`> diff ...` prefixes, arbitrary nesting - depth) -- Vim syntax fallback, configurable blend/priorities +- Email quoting/patch syntax support (`> diff ...`) +- Vim syntax fallback +- Configurable highlighiting blend & priorities +- Context-inclusive, high-accuracy highlights ## Requirements diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 3bb5a09..6798173 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -89,7 +89,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: max_lines = 500, }, vim = { - enabled = false, + enabled = true, max_lines = 200, }, intra = { @@ -204,7 +204,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: See |diffs.TreesitterConfig| for fields. {vim} (table, default: see below) - Vim syntax highlighting options (experimental). + Vim syntax fallback highlighting options. See |diffs.VimConfig| for fields. {intra} (table, default: see below) @@ -269,13 +269,15 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: *diffs.VimConfig* Vim config fields: ~ - {enabled} (boolean, default: false) + {enabled} (boolean, default: true) Use vim syntax highlighting as fallback when no treesitter parser is available for a language. Creates a scratch buffer, sets the filetype, and queries |synID()| per character to extract - highlight groups. Slower than treesitter but - covers languages without a TS parser installed. + highlight groups. Deferred via |vim.schedule()| + so it never blocks the first paint. Slower than + treesitter but covers languages without a TS + parser installed (e.g., COBOL, Fortran). {max_lines} (integer, default: 200) Skip vim syntax highlighting for hunks larger than diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d1cc7d2..0bfc373 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -124,7 +124,7 @@ local default_config = { max_lines = 500, }, vim = { - enabled = false, + enabled = true, max_lines = 200, }, intra = { From e7d56e3bbe3427d26f3e4772afa2816874d39675 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:14:31 -0500 Subject: [PATCH 41/55] feat(highlight): wire highlights.context into treesitter pipeline (#151) ## Problem `highlights.context.enabled` and `highlights.context.lines` were defined, validated, and range-checked but never read during highlighting. Hunks inside incomplete constructs (e.g., a table literal or function body whose opening is beyond the hunk's own context lines) parsed incorrectly because treesitter had no surrounding code. ## Solution `compute_hunk_context` in `init.lua` reads the working tree file using the hunk's `@@ +start,count @@` line numbers to collect up to `lines` (default 25) surrounding code lines in each direction. Files are read once via `io.open` and cached across hunks in the same file. `highlight_treesitter` in `highlight.lua` accepts an optional context parameter that prepends/appends context lines to the parse string and offsets capture rows by the prefix count, so extmarks only land on actual hunk lines. Wired through `highlight_hunk` for the two code-language treesitter calls (not headers, not `highlight_text`, not vim syntax). Closes #148. --- doc/diffs.nvim.txt | 32 ++-- lua/diffs/highlight.lua | 68 ++++++- lua/diffs/init.lua | 67 +++++++ lua/diffs/parser.lua | 2 + spec/context_spec.lua | 382 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 534 insertions(+), 17 deletions(-) create mode 100644 spec/context_spec.lua diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6798173..cfcd1b6 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -225,16 +225,20 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: *diffs.ContextConfig* Context config fields: ~ {enabled} (boolean, default: true) - Read lines from disk before and after each hunk - to provide surrounding syntax context. Improves - accuracy at hunk boundaries where incomplete - constructs (e.g., a function definition with no - body) would otherwise confuse the parser. + Read surrounding code from the working tree + file and feed it into the treesitter string + parser. Uses the hunk's `@@ +start,count @@` + line numbers to read lines before and after + the hunk from disk. Improves syntax accuracy + when the hunk is inside an incomplete construct + (e.g., a table literal or function body whose + opening is not visible in the hunk's own + context lines). {lines} (integer, default: 25) - Number of context lines to read in each - direction. Lines are read with early exit — - cost scales with this value, not file size. + Max context lines to read in each direction. + Files are read once per parse and cached across + hunks in the same file. *diffs.PrioritiesConfig* Priorities config fields: ~ @@ -695,10 +699,14 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation. Context lines within the hunk -(lines with a ` ` prefix) provide syntactic context for the parser. In rare -cases, hunks that start or end mid-expression may produce imperfect highlights -due to treesitter error recovery. +Treesitter parses each diff hunk in isolation. When `highlights.context` is +enabled (the default), surrounding code is read from the working tree file +and fed into the parser to improve accuracy at hunk boundaries. This helps +when a hunk is inside a table, function body, or loop whose opening is +beyond the hunk's own context lines. Requires `repo_root` and +`file_new_start` to be available on the hunk (true for standard unified +diffs). In rare cases, hunks that start or end mid-expression may still +produce imperfect highlights due to treesitter error recovery. Syntax Highlighting Flash ~ *diffs-flash* diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 3190641..493bcfc 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -67,6 +67,10 @@ end ---@field defer_vim_syntax? boolean ---@field syntax_only? boolean +---@class diffs.TSContext +---@field before string[]? +---@field after string[]? + ---@param bufnr integer ---@param ns integer ---@param code_lines string[] @@ -76,6 +80,7 @@ end ---@param covered_lines? table ---@param priorities diffs.PrioritiesConfig ---@param force_high_priority? boolean +---@param context? diffs.TSContext ---@return integer local function highlight_treesitter( bufnr, @@ -86,9 +91,34 @@ local function highlight_treesitter( col_offset, covered_lines, priorities, - force_high_priority + force_high_priority, + context ) - local code = table.concat(code_lines, '\n') + local prefix_count = 0 + local parse_lines = code_lines + if context then + local before = context.before + local after = context.after + if (before and #before > 0) or (after and #after > 0) then + parse_lines = {} + if before then + prefix_count = #before + for _, l in ipairs(before) do + parse_lines[#parse_lines + 1] = l + end + end + for _, l in ipairs(code_lines) do + parse_lines[#parse_lines + 1] = l + end + if after then + for _, l in ipairs(after) do + parse_lines[#parse_lines + 1] = l + end + end + end + end + + local code = table.concat(parse_lines, '\n') if code == '' then return 0 end @@ -118,6 +148,8 @@ local function highlight_treesitter( if capture ~= 'spell' and capture ~= 'nospell' then local capture_name = '@' .. capture .. '.' .. tree_lang local sr, sc, er, ec = node:range() + sr = sr - prefix_count + er = er - prefix_count local buf_sr = line_map[sr] if buf_sr then @@ -329,10 +361,36 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - extmark_count = - highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw + qw, covered_lines, p) + local ts_context = nil + if opts.highlights.context.enabled and (hunk.context_before or hunk.context_after) then + ts_context = { before = hunk.context_before, after = hunk.context_after } + end + + extmark_count = highlight_treesitter( + bufnr, + ns, + new_code, + hunk.lang, + new_map, + pw + qw, + covered_lines, + p, + nil, + ts_context + ) extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw + qw, covered_lines, p) + + highlight_treesitter( + bufnr, + ns, + old_code, + hunk.lang, + old_map, + pw + qw, + covered_lines, + p, + nil, + ts_context + ) if hunk.header_context and hunk.header_context_col then local header_extmarks = highlight_text( diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 0bfc373..77f521a 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -297,6 +297,69 @@ local function carry_forward_highlighted(old_entry, new_hunks) return highlighted end +---@param path string +---@return string[]? +local function read_file_lines(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local lines = {} + for line in f:lines() do + lines[#lines + 1] = line + end + f:close() + return lines +end + +---@param hunks diffs.Hunk[] +---@param max_lines integer +local function compute_hunk_context(hunks, max_lines) + ---@type table + local file_cache = {} + + for _, hunk in ipairs(hunks) do + if not hunk.repo_root or not hunk.filename or not hunk.file_new_start then + goto continue + end + + local path = vim.fs.joinpath(hunk.repo_root, hunk.filename) + local file_lines = file_cache[path] + if file_lines == nil then + file_lines = read_file_lines(path) or false + file_cache[path] = file_lines + end + if not file_lines then + goto continue + end + + local new_start = hunk.file_new_start + local new_count = hunk.file_new_count or 0 + local total = #file_lines + + local before_start = math.max(1, new_start - max_lines) + if before_start < new_start then + local before = {} + for i = before_start, new_start - 1 do + before[#before + 1] = file_lines[i] + end + hunk.context_before = before + end + + local after_start = new_start + new_count + local after_end = math.min(total, after_start + max_lines - 1) + if after_start <= total then + local after = {} + for i = after_start, after_end do + after[#after + 1] = file_lines[i] + end + hunk.context_after = after + end + + ::continue:: + end +end + ---@param bufnr integer local function ensure_cache(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) then @@ -321,6 +384,9 @@ local function ensure_cache(bufnr) local lc = vim.api.nvim_buf_line_count(bufnr) local bc = vim.api.nvim_buf_get_offset(bufnr, lc) dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + if config.highlights.context.enabled then + compute_hunk_context(hunks, config.highlights.context.lines) + end local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks) hunk_cache[bufnr] = { hunks = hunks, @@ -941,6 +1007,7 @@ M._test = { hunks_eq = hunks_eq, process_pending_clear = process_pending_clear, ft_retry_pending = ft_retry_pending, + compute_hunk_context = compute_hunk_context, } return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 3df3d4c..ccbd46a 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -15,6 +15,8 @@ ---@field prefix_width integer ---@field quote_width integer ---@field repo_root string? +---@field context_before string[]? +---@field context_after string[]? local M = {} diff --git a/spec/context_spec.lua b/spec/context_spec.lua new file mode 100644 index 0000000..237b7f3 --- /dev/null +++ b/spec/context_spec.lua @@ -0,0 +1,382 @@ +require('spec.helpers') +local diffs = require('diffs') +local highlight = require('diffs.highlight') +local compute_hunk_context = diffs._test.compute_hunk_context + +describe('context', function() + describe('compute_hunk_context', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + end) + + local function write_file(filename, lines) + local path = vim.fs.joinpath(tmpdir, filename) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + f:write(table.concat(lines, '\n') .. '\n') + f:close() + end + + local function make_hunk(filename, opts) + return { + filename = filename, + ft = 'lua', + lang = 'lua', + start_line = opts.start_line or 1, + lines = opts.lines, + prefix_width = opts.prefix_width or 1, + quote_width = 0, + repo_root = tmpdir, + file_new_start = opts.file_new_start, + file_new_count = opts.file_new_count, + } + end + + it('reads context_before from file lines preceding the hunk', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' local x = 1', + ' local y = 2', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 3, + file_new_count = 3, + lines = { ' local x = 1', '+local new = true', ' local y = 2' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'local M = {}', 'function M.foo()' }, hunks[1].context_before) + end) + + it('reads context_after from file lines following the hunk', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' local x = 1', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 2, + file_new_count = 2, + lines = { ' function M.foo()', '+ local x = 1' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'end', 'return M' }, hunks[1].context_after) + end) + + it('caps context_before to max_lines', function() + write_file('a.lua', { + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'target', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 6, + file_new_count = 1, + lines = { '+target' }, + }), + } + compute_hunk_context(hunks, 2) + + assert.same({ 'line4', 'line5' }, hunks[1].context_before) + end) + + it('caps context_after to max_lines', function() + write_file('a.lua', { + 'target', + 'after1', + 'after2', + 'after3', + 'after4', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+target' }, + }), + } + compute_hunk_context(hunks, 2) + + assert.same({ 'after1', 'after2' }, hunks[1].context_after) + end) + + it('skips hunks without file_new_start', function() + write_file('a.lua', { 'line1', 'line2' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = nil, + file_new_count = nil, + lines = { '+something' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('skips hunks without repo_root', function() + local hunks = { + { + filename = 'a.lua', + ft = 'lua', + lang = 'lua', + start_line = 1, + lines = { '+x' }, + prefix_width = 1, + quote_width = 0, + repo_root = nil, + file_new_start = 1, + file_new_count = 1, + }, + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('skips when file does not exist on disk', function() + local hunks = { + make_hunk('nonexistent.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+x' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('returns nil context_before for hunk at line 1', function() + write_file('a.lua', { 'first', 'second' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+first' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + end) + + it('returns nil context_after for hunk at end of file', function() + write_file('a.lua', { 'first', 'last' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 2, + lines = { ' first', '+last' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_after) + end) + + it('reads file once for multiple hunks in same file', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' return 1', + 'end', + 'function M.bar()', + ' return 2', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 2, + file_new_count = 3, + lines = { ' function M.foo()', '+ return 1', ' end' }, + }), + make_hunk('a.lua', { + file_new_start = 5, + file_new_count = 3, + lines = { ' function M.bar()', '+ return 2', ' end' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'local M = {}' }, hunks[1].context_before) + assert.same({ 'function M.bar()', ' return 2', 'end', 'return M' }, hunks[1].context_after) + assert.same({ + 'local M = {}', + 'function M.foo()', + ' return 1', + 'end', + }, hunks[2].context_before) + assert.same({ 'return M' }, hunks[2].context_after) + end) + end) + + describe('highlight_treesitter with context', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_context_test') + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) + end) + + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts(overrides) + local opts = { + hide_prefix = false, + highlights = { + background = false, + gutter = false, + context = { enabled = true, lines = 25 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + intra = { enabled = false, algorithm = 'default', max_lines = 500 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, + }, + } + if overrides then + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + end + return opts + end + + it('applies extmarks only to hunk lines, not context lines', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + ' local y = 2', + '+local z = 3', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', ' local y = 2', '+local z = 3' }, + prefix_width = 1, + quote_width = 0, + context_before = { 'local function foo()' }, + context_after = { 'end' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local row = mark[2] + assert.is_true(row >= 1 and row <= 3, 'extmark row ' .. row .. ' outside hunk range') + end + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('does not pass context when context.enabled = false', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 0, + context_before = { 'local function foo()' }, + context_after = { 'end' }, + } + + local opts_enabled = default_opts({ highlights = { context = { enabled = true } } }) + highlight.highlight_hunk(bufnr, ns, hunk, opts_enabled) + local extmarks_with = get_extmarks(bufnr) + + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + local opts_disabled = default_opts({ highlights = { context = { enabled = false } } }) + highlight.highlight_hunk(bufnr, ns, hunk, opts_disabled) + local extmarks_without = get_extmarks(bufnr) + + assert.is_true(#extmarks_with > 0) + assert.is_true(#extmarks_without > 0) + delete_buffer(bufnr) + end) + + it('skips context fields that are nil', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 0, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + end) +end) From 58589947e8ec31a7ea917fd0260a895d65cdb20d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:50:22 -0500 Subject: [PATCH 42/55] fix(highlight): use theme-agnostic fallbacks and retry when Normal has no background (#153) ## Problem Highlight group fallbacks in `compute_highlight_groups` were hardcoded to catppuccin mocha colors, producing wrong results for any other colorscheme when `Normal.bg` is nil. This happens on transparent terminals or when the colorscheme loads after the first diff buffer opens. ## Solution Replace hardcoded fallbacks with `vim.o.background`-aware neutral values. When `Normal.bg` is still absent after initial computation, schedule a single deferred retry via `vim.schedule` that recomputes and invalidates all attached buffer caches. Document the load-order requirement in the setup section. --- doc/diffs.nvim.txt | 4 ++++ lua/diffs/init.lua | 29 +++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index cfcd1b6..6288f0c 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -65,6 +65,9 @@ Install with lazy.nvim: >lua The plugin works automatically with no configuration required. For customization, see |diffs-config|. +NOTE: Load your colorscheme before `diffs.nvim`. For example, with lazy.nvim, +set `priority = 1000` and `lazy = false` on your colorscheme plugin. + ============================================================================== CONFIGURATION *diffs-config* @@ -758,6 +761,7 @@ character-level groups blend at 60% for more contrast. Line-number groups combine both: background from the line-level blend, foreground from the character-level blend. + Fugitive unified diff highlights: ~ *DiffsAdd* DiffsAdd Background for `+` lines. Derived by blending diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 77f521a..ca20308 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -162,6 +162,7 @@ local default_config = { local config = vim.deepcopy(default_config) local initialized = false +local hl_retry_pending = false ---@diagnostic disable-next-line: missing-fields local fast_hl_opts = {} ---@type diffs.HunkOpts @@ -470,11 +471,23 @@ local function compute_highlight_groups() local diff_added = resolve_hl('diffAdded') local diff_removed = resolve_hl('diffRemoved') - local bg = normal.bg or 0x1e1e2e - local add_bg = diff_add.bg or 0x2e4a3a - local del_bg = diff_delete.bg or 0x4a2e3a - local add_fg = diff_added.fg or diff_add.fg or 0x80c080 - local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080 + local dark = vim.o.background ~= 'light' + local bg = normal.bg or (dark and 0x1a1a1a or 0xf0f0f0) + local add_bg = diff_add.bg or (dark and 0x1a3a1a or 0xd0ffd0) + local del_bg = diff_delete.bg or (dark and 0x3a1a1a or 0xffd0d0) + local add_fg = diff_added.fg or diff_add.fg or (dark and 0x80d080 or 0x206020) + local del_fg = diff_removed.fg or diff_delete.fg or (dark and 0xd08080 or 0x802020) + + if not normal.bg and not hl_retry_pending then + hl_retry_pending = true + vim.schedule(function() + hl_retry_pending = false + compute_highlight_groups() + for bufnr, _ in pairs(attached_buffers) do + invalidate_cache(bufnr) + end + end) + end local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) @@ -483,7 +496,11 @@ local function compute_highlight_groups() local blended_add_text = blend_color(add_fg, bg, alpha) local blended_del_text = blend_color(del_fg, bg, alpha) - vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0, bg = bg }) + vim.api.nvim_set_hl( + 0, + 'DiffsClear', + { default = true, fg = normal.fg or (dark and 0xcccccc or 0x333333), bg = bg } + ) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) From c498fd2bac4d6b498082ee31f3ba6f2ede3bcd68 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:18:31 -0500 Subject: [PATCH 43/55] fix(init): migrate `vim.validate` to positional parameter API (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Neovim 0.11+ deprecated the table-based `vim.validate({...})` form. Every plugin load produces 5 deprecation warnings pointing at `init.lua` config validation. ## Solution Convert all `vim.validate` calls in `init()` to the new positional `vim.validate(name, value, validator, optional_or_msg)` form. No behavioral change — identical validation logic, just the calling convention. --- lua/diffs/init.lua | 187 +++++++++++++++++++++------------------------ 1 file changed, 85 insertions(+), 102 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index ca20308..7e5dd04 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -591,139 +591,122 @@ local function init() opts.neogit = {} end - vim.validate({ - debug = { - opts.debug, - function(v) - return v == nil or type(v) == 'boolean' or type(v) == 'string' - end, - 'boolean or string (file path)', - }, - hide_prefix = { opts.hide_prefix, 'boolean', true }, - fugitive = { - opts.fugitive, - function(v) - return v == nil or v == false or type(v) == 'table' - end, - 'table or false', - }, - neogit = { - opts.neogit, - function(v) - return v == nil or v == false or type(v) == 'table' - end, - 'table or false', - }, - extra_filetypes = { opts.extra_filetypes, 'table', true }, - highlights = { opts.highlights, 'table', true }, - }) + vim.validate('debug', opts.debug, function(v) + return v == nil or type(v) == 'boolean' or type(v) == 'string' + end, 'boolean or string (file path)') + vim.validate('hide_prefix', opts.hide_prefix, 'boolean', true) + vim.validate('fugitive', opts.fugitive, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') + vim.validate('neogit', opts.neogit, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') + vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) + vim.validate('highlights', opts.highlights, 'table', true) if opts.highlights then - vim.validate({ - ['highlights.background'] = { opts.highlights.background, 'boolean', true }, - ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, - ['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true }, - ['highlights.overrides'] = { opts.highlights.overrides, 'table', true }, - ['highlights.context'] = { opts.highlights.context, 'table', true }, - ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, - ['highlights.vim'] = { opts.highlights.vim, 'table', true }, - ['highlights.intra'] = { opts.highlights.intra, 'table', true }, - ['highlights.priorities'] = { opts.highlights.priorities, 'table', true }, - }) + vim.validate('highlights.background', opts.highlights.background, 'boolean', true) + vim.validate('highlights.gutter', opts.highlights.gutter, 'boolean', true) + vim.validate('highlights.blend_alpha', opts.highlights.blend_alpha, 'number', true) + vim.validate('highlights.overrides', opts.highlights.overrides, 'table', true) + vim.validate('highlights.context', opts.highlights.context, 'table', true) + vim.validate('highlights.treesitter', opts.highlights.treesitter, 'table', true) + vim.validate('highlights.vim', opts.highlights.vim, 'table', true) + vim.validate('highlights.intra', opts.highlights.intra, 'table', true) + vim.validate('highlights.priorities', opts.highlights.priorities, 'table', true) if opts.highlights.context then - vim.validate({ - ['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true }, - ['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true }, - }) + vim.validate('highlights.context.enabled', opts.highlights.context.enabled, 'boolean', true) + vim.validate('highlights.context.lines', opts.highlights.context.lines, 'number', true) end if opts.highlights.treesitter then - vim.validate({ - ['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true }, - ['highlights.treesitter.max_lines'] = { - opts.highlights.treesitter.max_lines, - 'number', - true, - }, - }) + vim.validate( + 'highlights.treesitter.enabled', + opts.highlights.treesitter.enabled, + 'boolean', + true + ) + vim.validate( + 'highlights.treesitter.max_lines', + opts.highlights.treesitter.max_lines, + 'number', + true + ) end if opts.highlights.vim then - vim.validate({ - ['highlights.vim.enabled'] = { opts.highlights.vim.enabled, 'boolean', true }, - ['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true }, - }) + vim.validate('highlights.vim.enabled', opts.highlights.vim.enabled, 'boolean', true) + vim.validate('highlights.vim.max_lines', opts.highlights.vim.max_lines, 'number', true) end if opts.highlights.intra then - vim.validate({ - ['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true }, - ['highlights.intra.algorithm'] = { - opts.highlights.intra.algorithm, - function(v) - return v == nil or v == 'default' or v == 'vscode' - end, - "'default' or 'vscode'", - }, - ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, - }) + vim.validate('highlights.intra.enabled', opts.highlights.intra.enabled, 'boolean', true) + vim.validate('highlights.intra.algorithm', opts.highlights.intra.algorithm, function(v) + return v == nil or v == 'default' or v == 'vscode' + end, "'default' or 'vscode'") + vim.validate('highlights.intra.max_lines', opts.highlights.intra.max_lines, 'number', true) end if opts.highlights.priorities then - vim.validate({ - ['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true }, - ['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true }, - ['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true }, - ['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true }, - }) + vim.validate('highlights.priorities.clear', opts.highlights.priorities.clear, 'number', true) + vim.validate( + 'highlights.priorities.syntax', + opts.highlights.priorities.syntax, + 'number', + true + ) + vim.validate( + 'highlights.priorities.line_bg', + opts.highlights.priorities.line_bg, + 'number', + true + ) + vim.validate( + 'highlights.priorities.char_bg', + opts.highlights.priorities.char_bg, + 'number', + true + ) end end if type(opts.fugitive) == 'table' then ---@type diffs.FugitiveConfig local fug = opts.fugitive - vim.validate({ - ['fugitive.horizontal'] = { - fug.horizontal, - function(v) - return v == nil or v == false or type(v) == 'string' - end, - 'string or false', - }, - ['fugitive.vertical'] = { - fug.vertical, - function(v) - return v == nil or v == false or type(v) == 'string' - end, - 'string or false', - }, - }) + vim.validate('fugitive.horizontal', fug.horizontal, function(v) + return v == nil or v == false or type(v) == 'string' + end, 'string or false') + vim.validate('fugitive.vertical', fug.vertical, function(v) + return v == nil or v == false or type(v) == 'string' + end, 'string or false') end if opts.conflict then - vim.validate({ - ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, - ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, - ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, - ['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true }, - ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true }, - ['conflict.priority'] = { opts.conflict.priority, 'number', true }, - ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, - }) + vim.validate('conflict.enabled', opts.conflict.enabled, 'boolean', true) + vim.validate('conflict.disable_diagnostics', opts.conflict.disable_diagnostics, 'boolean', true) + vim.validate('conflict.show_virtual_text', opts.conflict.show_virtual_text, 'boolean', true) + vim.validate( + 'conflict.format_virtual_text', + opts.conflict.format_virtual_text, + 'function', + true + ) + vim.validate('conflict.show_actions', opts.conflict.show_actions, 'boolean', true) + vim.validate('conflict.priority', opts.conflict.priority, 'number', true) + vim.validate('conflict.keymaps', opts.conflict.keymaps, 'table', true) if opts.conflict.keymaps then local keymap_validator = function(v) return v == false or type(v) == 'string' end for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do - vim.validate({ - ['conflict.keymaps.' .. key] = { - opts.conflict.keymaps[key], - keymap_validator, - 'string or false', - }, - }) + vim.validate( + 'conflict.keymaps.' .. key, + opts.conflict.keymaps[key], + keymap_validator, + 'string or false' + ) end end end From 993fed4a451650693bfb3354c63162ec2d30777a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:42:02 -0500 Subject: [PATCH 44/55] feat: gitsigns blame popup highlighting (#157) ## Problem gitsigns' `:Gitsigns blame_line` popup shows flat `GitSignsAddPreview`/`GitSignsDeletePreview` line highlights with basic word-level inline diffs, but no treesitter syntax or diffs.nvim's character-level intra-line highlighting. ## Solution Add `lua/diffs/gitsigns.lua` which patches gitsigns' `Popup.create` and `Popup.update` to intercept blame popups. Parses `Hunk N of M` sections from the popup buffer, clears gitsigns' own `gitsigns_popup` namespace on the diff region, and applies `highlight_hunk` with manual `@diff.plus`/`@diff.minus` prefix extmarks. Uses a separate `diffs-gitsigns` namespace to avoid colliding with the main decoration provider. Enabled via `vim.g.diffs = { gitsigns = true }`. Wired in `plugin/diffs.lua` with a `User GitAttach` lazy-load retry for when gitsigns loads after diffs.nvim. Config plumbing adds `get_highlight_opts()` as a public getter, replacing the `debug.getupvalue` hack used by the standalone `blame_hl.nvim` plugin. Closes #155. --- README.md | 9 +- doc/diffs.nvim.txt | 53 +++++++-- lua/diffs/gitsigns.lua | 172 ++++++++++++++++++++++++++++++ lua/diffs/init.lua | 17 +++ lua/diffs/parser.lua | 2 + plugin/diffs.lua | 13 +++ spec/gitsigns_spec.lua | 236 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 lua/diffs/gitsigns.lua create mode 100644 spec/gitsigns_spec.lua diff --git a/README.md b/README.md index 2a7bd73..0051f21 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ with language-aware syntax highlighting. word-level accuracy) - `:Gdiff` unified diff against any revision - Inline merge conflict detection, highlighting, and resolution +- gitsigns.nvim blame popup highlighting - Email quoting/patch syntax support (`> diff ...`) - Vim syntax fallback - Configurable highlighiting blend & priorities @@ -58,14 +59,15 @@ luarocks install diffs.nvim Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to control loading - `diffs.nvim` lazy-loads itself. -**Q: Does diffs.nvim support vim-fugitive/Neogit?** +**Q: Does diffs.nvim support vim-fugitive/Neogit/gitsigns?** -Yes. Enable it in your config: +Yes. Enable integrations in your config: ```lua vim.g.diffs = { fugitive = true, neogit = true, + gitsigns = true, } ``` @@ -119,4 +121,5 @@ See the documentation for more information. - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [@phanen](https://github.com/phanen) - diff header highlighting, unknown filetype fix, shebang/modeline detection, treesitter injection support, - decoration provider highlighting architecture + decoration provider highlighting architecture, gitsigns blame popup + highlighting diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6288f0c..f0205d5 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -15,6 +15,7 @@ Features: ~ - Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs - |:Gdiff| command for unified diff against any git revision +- gitsigns.nvim blame popup highlighting (see |diffs-gitsigns|) - Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility @@ -35,12 +36,13 @@ CONTENTS *diffs-contents* 8. Conflict Resolution .................................... |diffs-conflict| 9. Merge Diff Resolution ..................................... |diffs-merge| 10. Neogit ................................................... |diffs-neogit| - 11. API ......................................................... |diffs-api| - 12. Implementation ................................... |diffs-implementation| - 13. Known Limitations ................................... |diffs-limitations| - 14. Highlight Groups ..................................... |diffs-highlights| - 15. Health Check ............................................. |diffs-health| - 16. Acknowledgements ............................... |diffs-acknowledgements| + 11. Gitsigns ................................................ |diffs-gitsigns| + 12. API ......................................................... |diffs-api| + 13. Implementation ................................... |diffs-implementation| + 14. Known Limitations ................................... |diffs-limitations| + 15. Highlight Groups ..................................... |diffs-highlights| + 16. Health Check ............................................. |diffs-health| + 17. Acknowledgements ............................... |diffs-acknowledgements| ============================================================================== REQUIREMENTS *diffs-requirements* @@ -78,6 +80,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: hide_prefix = false, fugitive = false, neogit = false, + gitsigns = false, extra_filetypes = {}, highlights = { background = true, @@ -161,6 +164,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { neogit = true } < + {gitsigns} (boolean|table, default: false) + Enable gitsigns.nvim blame popup highlighting. + Pass `true` or `{}` to enable, `false` to + disable. When active, `:Gitsigns blame_line` + popups receive treesitter syntax, line + backgrounds, and intra-line character diffs. + See |diffs-gitsigns|. >lua + vim.g.diffs = { gitsigns = true } +< + {extra_filetypes} (table, default: {}) Additional filetypes to attach to, beyond the built-in `git`, `gitcommit`, and any enabled @@ -650,6 +663,31 @@ line visuals. The overrides are reapplied on `ColorScheme` since Neogit re-defines its groups then. When `neogit = false`, no highlight overrides are applied. +============================================================================== +GITSIGNS *diffs-gitsigns* + +diffs.nvim can enhance gitsigns.nvim blame popups with syntax highlighting. +Enable gitsigns support in your config: >lua + vim.g.diffs = { gitsigns = true } +< + +When `:Gitsigns blame_line full=true` opens a popup, diffs.nvim intercepts +the popup and replaces gitsigns' flat `GitSignsAddPreview`/ +`GitSignsDeletePreview` highlights with: + +- Treesitter syntax highlighting on the code content +- `DiffsAdd`/`DiffsDelete` line backgrounds +- Character-level intra-line diffs (`DiffsAddText`/`DiffsDeleteText`) +- `@diff.plus`/`@diff.minus` coloring on `+`/`-` prefix characters + +The integration patches `gitsigns.popup.create` and `gitsigns.popup.update` +so highlights persist across gitsigns' two-phase render (initial popup, then +update with GitHub/PR data). If gitsigns loads after diffs.nvim, a +`User GitAttach` autocmd retries the setup automatically. + +Highlights are applied in a separate `diffs-gitsigns` namespace and do not +interfere with the main decoration provider used for diff buffers. + ============================================================================== API *diffs-api* @@ -878,7 +916,8 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements* - codediff.nvim (https://github.com/esmuellert/codediff.nvim) - diffview.nvim (https://github.com/sindrets/diffview.nvim) - @phanen (https://github.com/phanen) - diff header highlighting, - treesitter injection support + treesitter injection support, blame_hl.nvim (gitsigns blame popup + highlighting inspiration) ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/gitsigns.lua b/lua/diffs/gitsigns.lua new file mode 100644 index 0000000..0439fb2 --- /dev/null +++ b/lua/diffs/gitsigns.lua @@ -0,0 +1,172 @@ +local M = {} + +local api = vim.api +local fn = vim.fn +local dbg = require('diffs.log').dbg + +local ns = api.nvim_create_namespace('diffs-gitsigns') +local gs_popup_ns = api.nvim_create_namespace('gitsigns_popup') + +local patched = false + +---@param bufnr integer +---@param src_filename string +---@param src_ft string? +---@param src_lang string? +---@return diffs.Hunk[] +function M.parse_blame_hunks(bufnr, src_filename, src_ft, src_lang) + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + local hunks = {} + local hunk_lines = {} + local hunk_start = nil + + for i, line in ipairs(lines) do + if line:match('^Hunk %d+ of %d+') then + if hunk_start and #hunk_lines > 0 then + table.insert(hunks, { + filename = src_filename, + ft = src_ft, + lang = src_lang, + start_line = hunk_start, + prefix_width = 1, + quote_width = 0, + lines = hunk_lines, + }) + end + hunk_lines = {} + hunk_start = i + elseif hunk_start then + if line:match('^%(guessed:') then + hunk_start = i + else + local prefix = line:sub(1, 1) + if prefix == ' ' or prefix == '+' or prefix == '-' then + if #hunk_lines == 0 then + hunk_start = i - 1 + end + table.insert(hunk_lines, line) + end + end + end + end + + if hunk_start and #hunk_lines > 0 then + table.insert(hunks, { + filename = src_filename, + ft = src_ft, + lang = src_lang, + start_line = hunk_start, + prefix_width = 1, + quote_width = 0, + lines = hunk_lines, + }) + end + + return hunks +end + +---@param preview_winid integer +---@param preview_bufnr integer +local function on_preview(preview_winid, preview_bufnr) + local ok, err = pcall(function() + if not api.nvim_buf_is_valid(preview_bufnr) then + return + end + if not api.nvim_win_is_valid(preview_winid) then + return + end + + local win = api.nvim_get_current_win() + if win == preview_winid then + win = fn.win_getid(fn.winnr('#')) + end + if win == -1 or win == 0 or not api.nvim_win_is_valid(win) then + return + end + + local srcbuf = api.nvim_win_get_buf(win) + if not api.nvim_buf_is_loaded(srcbuf) then + return + end + + local ft = vim.bo[srcbuf].filetype + local name = api.nvim_buf_get_name(srcbuf) + if not name or name == '' then + name = ft and ('a.' .. ft) or 'unknown' + end + local lang = ft and require('diffs.parser').get_lang_from_ft(ft) or nil + + local hunks = M.parse_blame_hunks(preview_bufnr, name, ft, lang) + if #hunks == 0 then + return + end + + local diff_start = hunks[1].start_line + local last = hunks[#hunks] + local diff_end = last.start_line + #last.lines + + api.nvim_buf_clear_namespace(preview_bufnr, gs_popup_ns, diff_start, diff_end) + api.nvim_buf_clear_namespace(preview_bufnr, ns, diff_start, diff_end) + + local opts = require('diffs').get_highlight_opts() + local highlight = require('diffs.highlight') + for _, hunk in ipairs(hunks) do + highlight.highlight_hunk(preview_bufnr, ns, hunk, opts) + for j, line in ipairs(hunk.lines) do + local ch = line:sub(1, 1) + if ch == '+' or ch == '-' then + pcall(api.nvim_buf_set_extmark, preview_bufnr, ns, hunk.start_line + j - 1, 0, { + end_col = 1, + hl_group = ch == '+' and '@diff.plus' or '@diff.minus', + priority = opts.highlights.priorities.syntax, + }) + end + end + end + + dbg('gitsigns blame: highlighted %d hunks in popup buf %d', #hunks, preview_bufnr) + end) + if not ok then + dbg('gitsigns blame error: %s', err) + end +end + +---@return boolean +function M.setup() + if patched then + return true + end + + local pop_ok, Popup = pcall(require, 'gitsigns.popup') + if not pop_ok or not Popup then + return false + end + + Popup.create = (function(orig) + return function(...) + local winid, bufnr = orig(...) + on_preview(winid, bufnr) + return winid, bufnr + end + end)(Popup.create) + + Popup.update = (function(orig) + return function(winid, bufnr, ...) + orig(winid, bufnr, ...) + on_preview(winid, bufnr) + end + end)(Popup.update) + + patched = true + dbg('gitsigns popup patched') + return true +end + +M._test = { + parse_blame_hunks = M.parse_blame_hunks, + on_preview = on_preview, + ns = ns, + gs_popup_ns = gs_popup_ns, +} + +return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 7e5dd04..06b6ad9 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -38,6 +38,8 @@ ---@class diffs.NeogitConfig +---@class diffs.GitsignsConfig + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -62,6 +64,7 @@ ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig|false ---@field neogit diffs.NeogitConfig|false +---@field gitsigns diffs.GitsignsConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -141,6 +144,7 @@ local default_config = { }, fugitive = false, neogit = false, + gitsigns = false, conflict = { enabled = true, disable_diagnostics = true, @@ -591,6 +595,10 @@ local function init() opts.neogit = {} end + if opts.gitsigns == true then + opts.gitsigns = {} + end + vim.validate('debug', opts.debug, function(v) return v == nil or type(v) == 'boolean' or type(v) == 'string' end, 'boolean or string (file path)') @@ -601,6 +609,9 @@ local function init() vim.validate('neogit', opts.neogit, function(v) return v == nil or v == false or type(v) == 'table' end, 'table or false') + vim.validate('gitsigns', opts.gitsigns, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) vim.validate('highlights', opts.highlights, 'table', true) @@ -990,6 +1001,12 @@ function M.get_conflict_config() return config.conflict end +---@return diffs.HunkOpts +function M.get_highlight_opts() + init() + return { hide_prefix = config.hide_prefix, highlights = config.highlights } +end + local function process_pending_clear(bufnr) local entry = hunk_cache[bufnr] if entry and entry.pending_clear then diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index ccbd46a..aa63daf 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -353,6 +353,8 @@ function M.parse_buffer(bufnr) return hunks end +M.get_lang_from_ft = get_lang_from_ft + M._test = { ft_lang_cache = ft_lang_cache, } diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e572cf5..340df9c 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -5,6 +5,19 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() +local gs_cfg = (vim.g.diffs or {}).gitsigns +if gs_cfg == true or type(gs_cfg) == 'table' then + if not require('diffs.gitsigns').setup() then + vim.api.nvim_create_autocmd('User', { + pattern = 'GitAttach', + once = true, + callback = function() + require('diffs.gitsigns').setup() + end, + }) + end +end + vim.api.nvim_create_autocmd('FileType', { pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), callback = function(args) diff --git a/spec/gitsigns_spec.lua b/spec/gitsigns_spec.lua new file mode 100644 index 0000000..b2b6e2a --- /dev/null +++ b/spec/gitsigns_spec.lua @@ -0,0 +1,236 @@ +require('spec.helpers') +local gs = require('diffs.gitsigns') + +local function setup_highlight_groups() + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) + local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a }) + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) +end + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +describe('gitsigns', function() + describe('parse_blame_hunks', function() + it('parses a single hunk', function() + local bufnr = create_buffer({ + 'commit abc1234', + 'Author: Test User', + '', + 'Hunk 1 of 1', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') + assert.are.equal(1, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal('lua', hunks[1].lang) + assert.are.equal(1, hunks[1].prefix_width) + assert.are.equal(0, hunks[1].quote_width) + assert.are.equal(4, #hunks[1].lines) + assert.are.equal(4, hunks[1].start_line) + assert.are.equal(' local x = 1', hunks[1].lines[1]) + assert.are.equal('-local y = 2', hunks[1].lines[2]) + assert.are.equal('+local y = 3', hunks[1].lines[3]) + delete_buffer(bufnr) + end) + + it('parses multiple hunks', function() + local bufnr = create_buffer({ + 'commit abc1234', + '', + 'Hunk 1 of 2', + '-local a = 1', + '+local a = 2', + 'Hunk 2 of 2', + ' local b = 3', + '+local c = 4', + }) + local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') + assert.are.equal(2, #hunks) + assert.are.equal(2, #hunks[1].lines) + assert.are.equal(2, #hunks[2].lines) + delete_buffer(bufnr) + end) + + it('skips guessed-offset lines', function() + local bufnr = create_buffer({ + 'commit abc1234', + '', + 'Hunk 1 of 1', + '(guessed: hunk offset may be wrong)', + ' local x = 1', + '+local y = 2', + }) + local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') + assert.are.equal(1, #hunks) + assert.are.equal(2, #hunks[1].lines) + assert.are.equal(' local x = 1', hunks[1].lines[1]) + delete_buffer(bufnr) + end) + + it('returns empty table when no hunks present', function() + local bufnr = create_buffer({ + 'commit abc1234', + 'Author: Test User', + 'Date: 2024-01-01', + }) + local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') + assert.are.equal(0, #hunks) + delete_buffer(bufnr) + end) + + it('handles hunk with no diff lines after header', function() + local bufnr = create_buffer({ + 'Hunk 1 of 1', + 'some non-diff text', + }) + local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') + assert.are.equal(0, #hunks) + delete_buffer(bufnr) + end) + end) + + describe('on_preview', function() + before_each(function() + setup_highlight_groups() + end) + + it('applies extmarks to popup buffer with diff content', function() + local bufnr = create_buffer({ + 'commit abc1234', + '', + 'Hunk 1 of 1', + ' local x = 1', + '-local y = 2', + '+local y = 3', + }) + + local winid = vim.api.nvim_open_win(bufnr, false, { + relative = 'editor', + width = 40, + height = 10, + row = 0, + col = 0, + }) + + gs._test.on_preview(winid, bufnr) + + local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, gs._test.ns, 0, -1, { details = true }) + assert.is_true(#extmarks > 0) + + vim.api.nvim_win_close(winid, true) + delete_buffer(bufnr) + end) + + it('clears gitsigns_popup namespace on diff region', function() + local bufnr = create_buffer({ + 'commit abc1234', + '', + 'Hunk 1 of 1', + ' local x = 1', + '+local y = 2', + }) + + vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 3, 0, { + end_col = 12, + hl_group = 'GitSignsAddPreview', + }) + vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 4, 0, { + end_col = 12, + hl_group = 'GitSignsAddPreview', + }) + + local winid = vim.api.nvim_open_win(bufnr, false, { + relative = 'editor', + width = 40, + height = 10, + row = 0, + col = 0, + }) + + gs._test.on_preview(winid, bufnr) + + local gs_extmarks = + vim.api.nvim_buf_get_extmarks(bufnr, gs._test.gs_popup_ns, 0, -1, { details = true }) + assert.are.equal(0, #gs_extmarks) + + vim.api.nvim_win_close(winid, true) + delete_buffer(bufnr) + end) + + it('does not error on invalid buffer', function() + assert.has_no.errors(function() + gs._test.on_preview(0, 99999) + end) + end) + end) + + describe('setup', function() + it('returns false when gitsigns.popup is not available', function() + local saved = package.loaded['gitsigns.popup'] + package.loaded['gitsigns.popup'] = nil + package.preload['gitsigns.popup'] = nil + + local fresh = loadfile('lua/diffs/gitsigns.lua')() + local result = fresh.setup() + assert.is_false(result) + + package.loaded['gitsigns.popup'] = saved + end) + + it('patches gitsigns.popup when available', function() + local create_called = false + local update_called = false + local mock_popup = { + create = function() + create_called = true + local bufnr = create_buffer({ 'test' }) + local winid = vim.api.nvim_open_win(bufnr, false, { + relative = 'editor', + width = 10, + height = 1, + row = 0, + col = 0, + }) + return winid, bufnr + end, + update = function() + update_called = true + end, + } + + local saved = package.loaded['gitsigns.popup'] + package.loaded['gitsigns.popup'] = mock_popup + + local fresh = loadfile('lua/diffs/gitsigns.lua')() + local result = fresh.setup() + assert.is_true(result) + + mock_popup.create() + assert.is_true(create_called) + + mock_popup.update(0, 0) + assert.is_true(update_called) + + package.loaded['gitsigns.popup'] = saved + end) + end) +end) From b2fb49d48bb94b22910dc8972cdab404e909ee7f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:13:53 -0500 Subject: [PATCH 45/55] fix: gate `ft=git` attachment on `fugitive` config toggle (#163) ## Problem The `is_fugitive_buffer` guard in the `FileType` callback checked the buffer name for `fugitive://` without checking whether the `fugitive` integration was enabled. `ft=git` fugitive buffers got highlighted even with `fugitive = false` (the default). ## Solution Check `get_fugitive_config()` before `is_fugitive_buffer()`. When `fugitive = false` (default), no `ft=git` buffer gets through the guard. --- plugin/diffs.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 340df9c..43c5564 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -22,8 +22,10 @@ vim.api.nvim_create_autocmd('FileType', { pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), callback = function(args) local diffs = require('diffs') - if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then - return + if args.match == 'git' then + if not diffs.get_fugitive_config() or not diffs.is_fugitive_buffer(args.buf) then + return + end end diffs.attach(args.buf) From cb852d115b0417ad0e256e6db6685428791b32ee Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:22:13 -0500 Subject: [PATCH 46/55] docs: document picker integration via `extra_filetypes` (#164) ## Problem `extra_filetypes = { 'diff' }` enables highlighting in telescope, snacks, and fzf-lua git preview buffers, but this was not documented beyond a brief mention of `.diff` files. ## Solution Add a README FAQ entry and expand the vimdoc `extra_filetypes` field description to mention specific pickers and which previewer styles are supported. --- doc/diffs.nvim.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index f0205d5..e1541e2 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -184,6 +184,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: extra_filetypes = { 'diff' }, } < + Adding `'diff'` also enables highlighting in + picker preview buffers that set `filetype=diff`: + telescope.nvim (git_commits, git_bcommits, + git_status), snacks.nvim (syntax style only), + and fzf-lua (builtin previewer only). Terminal- + based previewers are not supported. {highlights} (table, default: see below) Controls which highlight features are enabled. From 8122f23541216eb31d2cce2980c7056961eb66eb Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:26:35 -0500 Subject: [PATCH 47/55] docs: restructure vimdoc with integrations parent section (#165) ## Problem Integration docs (fugitive, neogit, gitsigns) were scattered as top-level sections with no grouping, making it hard to find all supported plugin integrations in one place. ## Solution Add `|diffs-integrations|` parent section that groups all integration subsections under a single TOC entry. Fugitive, neogit, and gitsigns are now subsections (using `---` separators) under the new `INTEGRATIONS` heading. The intro paragraph documents the two attachment patterns: automatic (config toggles like `fugitive = true`) and opt-in (`extra_filetypes`). Conflict resolution and merge diff resolution remain as standalone top-level sections. TOC renumbered accordingly. --- doc/diffs.nvim.txt | 130 +++++++++++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index e1541e2..43e949a 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -32,17 +32,18 @@ CONTENTS *diffs-contents* 4. Configuration ............................................ |diffs-config| 5. Commands ............................................... |diffs-commands| 6. Mappings ............................................... |diffs-mappings| - 7. Fugitive Status Keymaps ................................ |diffs-fugitive| + 7. Integrations ..................................... |diffs-integrations| + Fugitive .......................................... |diffs-fugitive| + Neogit .............................................. |diffs-neogit| + Gitsigns .......................................... |diffs-gitsigns| 8. Conflict Resolution .................................... |diffs-conflict| 9. Merge Diff Resolution ..................................... |diffs-merge| - 10. Neogit ................................................... |diffs-neogit| - 11. Gitsigns ................................................ |diffs-gitsigns| - 12. API ......................................................... |diffs-api| - 13. Implementation ................................... |diffs-implementation| - 14. Known Limitations ................................... |diffs-limitations| - 15. Highlight Groups ..................................... |diffs-highlights| - 16. Health Check ............................................. |diffs-health| - 17. Acknowledgements ............................... |diffs-acknowledgements| + 10. API ......................................................... |diffs-api| + 11. Implementation ................................... |diffs-implementation| + 12. Known Limitations ................................... |diffs-limitations| + 13. Highlight Groups ..................................... |diffs-highlights| + 14. Health Check ............................................. |diffs-health| + 15. Acknowledgements ............................... |diffs-acknowledgements| ============================================================================== REQUIREMENTS *diffs-requirements* @@ -453,10 +454,44 @@ Diff buffer mappings: ~ or the fugitive status keymaps. ============================================================================== -FUGITIVE STATUS KEYMAPS *diffs-fugitive* +INTEGRATIONS *diffs-integrations* -When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps -to open unified diffs for files or entire sections. +diffs.nvim integrates with several plugins. There are two attachment +patterns: + +Automatic: ~ +Enable via config toggles. The plugin registers `FileType` autocmds for +each integration's filetypes and attaches automatically. +>lua + vim.g.diffs = { + fugitive = true, + neogit = true, + gitsigns = true, + } +< + +Opt-in: ~ +For filetypes not covered by a built-in integration, use `extra_filetypes` +to attach to any buffer whose content looks like a diff. +>lua + vim.g.diffs = { extra_filetypes = { 'diff' } } +< + +------------------------------------------------------------------------------ +FUGITIVE *diffs-fugitive* + +Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua + vim.g.diffs = { fugitive = true } +< + +|:Git| status and commit views receive treesitter syntax, line backgrounds, +and intra-line diffs. |:Gdiff| opens a unified diff against any revision +(see |diffs-commands|). + +Fugitive status keymaps: ~ + +When inside a |:Git| status buffer, diffs.nvim provides keymaps to open +unified diffs for files or entire sections. Keymaps: ~ *diffs-du* *diffs-dU* @@ -505,6 +540,31 @@ Configuration: ~ Keymap for unified diff in vertical split. Set to `false` to disable. +------------------------------------------------------------------------------ +NEOGIT *diffs-neogit* + +Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua + vim.g.diffs = { neogit = true } +< + +Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status +view) applies treesitter syntax highlighting and intra-line diffs to the +hunk lines. + +------------------------------------------------------------------------------ +GITSIGNS *diffs-gitsigns* + +Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) blame +popup highlighting: >lua + vim.g.diffs = { gitsigns = true } +< + +`:Gitsigns blame_line full=true` popups receive treesitter syntax, line +backgrounds, and intra-line diffs. + +Highlights are applied in a separate `diffs-gitsigns` namespace and do not +interfere with the main decoration provider used for diff buffers. + ============================================================================== CONFLICT RESOLUTION *diffs-conflict* @@ -648,52 +708,6 @@ The working file buffer is modified in place; save it when ready. Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed automatically after each resolution. -============================================================================== -NEOGIT *diffs-neogit* - -diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of -the box. Enable Neogit support in your config: >lua - vim.g.diffs = { neogit = true } -< - -When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the -status view), diffs.nvim applies treesitter syntax highlighting and -intra-line diffs to the hunk lines, just as it does for fugitive. - -Neogit highlight overrides: ~ -On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff -highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`, -`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by -setting them to empty (`{}`). This gives diffs.nvim sole control of diff -line visuals. The overrides are reapplied on `ColorScheme` since Neogit -re-defines its groups then. When `neogit = false`, no highlight overrides -are applied. - -============================================================================== -GITSIGNS *diffs-gitsigns* - -diffs.nvim can enhance gitsigns.nvim blame popups with syntax highlighting. -Enable gitsigns support in your config: >lua - vim.g.diffs = { gitsigns = true } -< - -When `:Gitsigns blame_line full=true` opens a popup, diffs.nvim intercepts -the popup and replaces gitsigns' flat `GitSignsAddPreview`/ -`GitSignsDeletePreview` highlights with: - -- Treesitter syntax highlighting on the code content -- `DiffsAdd`/`DiffsDelete` line backgrounds -- Character-level intra-line diffs (`DiffsAddText`/`DiffsDeleteText`) -- `@diff.plus`/`@diff.minus` coloring on `+`/`-` prefix characters - -The integration patches `gitsigns.popup.create` and `gitsigns.popup.update` -so highlights persist across gitsigns' two-phase render (initial popup, then -update with GitHub/PR data). If gitsigns loads after diffs.nvim, a -`User GitAttach` autocmd retries the setup automatically. - -Highlights are applied in a separate `diffs-gitsigns` namespace and do not -interfere with the main decoration provider used for diff buffers. - ============================================================================== API *diffs-api* From d06144450cf6dbee6fac2cd2b41f5cdef211a067 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:31:40 -0500 Subject: [PATCH 48/55] docs: revamp vimdoc structure and content (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The vimdoc had several issues: integration sections (fugitive, neogit, gitsigns) were scattered as top-level entries with no grouping, the intro implied diffs.nvim works automatically with fugitive/neogit out of the box, the neogit section described a highlight override workaround that was already removed, and `extra_filetypes` didn't mention picker support. ## Solution - Rewrite intro to document default behavior: `gitcommit` highlighting, conflict detection, `&diff` winhighlight — everything else is opt-in - Remove vim-fugitive as a dependency in setup - Add `|diffs-integrations|` parent section grouping fugitive, neogit, and gitsigns - Add fugitive intro paragraph mentioning `:Gdiff` - Trim neogit section (remove stale highlight override docs) - Trim gitsigns section (remove implementation details) - Add `committia` config field docs - Expand `extra_filetypes` docs to mention telescope, snacks, and fzf-lua --- doc/diffs.nvim.txt | 58 ++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 43e949a..85b8cb5 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -6,22 +6,27 @@ License: MIT ============================================================================== INTRODUCTION *diffs.nvim* -diffs.nvim adds syntax highlighting to diff views. It overlays language-aware -highlights on top of default diff highlighting in vim-fugitive, Neogit, and -Neovim's built-in diff mode. +diffs.nvim adds language-aware syntax highlighting to unified diff content +in Neovim buffers. It replaces flat `diffAdded`/`diffRemoved` coloring with +treesitter syntax, blended line backgrounds, and character-level intra-line +diffs. + +With no configuration, diffs.nvim provides: +- `gitcommit` diff highlighting (e.g., `git commit --verbose`) +- Inline merge conflict detection, highlighting, and resolution +- Background-only diff colors for `&diff` buffers (vimdiff, diffthis) + +All other integrations are opt-in. See |diffs-integrations|. Features: ~ -- Syntax highlighting in |:Git| summary diffs and commit detail views -- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) -- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs -- |:Gdiff| command for unified diff against any git revision -- gitsigns.nvim blame popup highlighting (see |diffs-gitsigns|) -- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) +- Treesitter syntax highlighting in diff hunks +- Character-level intra-line diff highlighting - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility - Optional diff prefix (`+`/`-`/` `) concealment - Gutter (line number) highlighting -- Inline merge conflict marker detection, highlighting, and resolution +- |:Gdiff| unified diff against any revision +- Email quoting/patch syntax support (`> diff ...`) ============================================================================== CONTENTS *diffs-contents* @@ -49,27 +54,23 @@ CONTENTS *diffs-contents* REQUIREMENTS *diffs-requirements* - Neovim 0.9.0+ -- vim-fugitive (https://github.com/tpope/vim-fugitive) (optional, for unified - diff syntax highlighting in |:Git| and commit views) - Treesitter parsers for languages you want highlighted -Note: The diff mode feature (background-only colors for |:diffthis|, vimdiff, -etc.) works without vim-fugitive. - ============================================================================== SETUP *diffs-setup* Install with lazy.nvim: >lua - { - 'barrettruth/diffs.nvim', - dependencies = { 'tpope/vim-fugitive' }, - } + { 'barrettruth/diffs.nvim' } < -The plugin works automatically with no configuration required. For -customization, see |diffs-config|. -NOTE: Load your colorscheme before `diffs.nvim`. For example, with lazy.nvim, -set `priority = 1000` and `lazy = false` on your colorscheme plugin. +Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys` — +diffs.nvim lazy-loads itself. + +NOTE: Load your colorscheme before diffs.nvim. With lazy.nvim, set +`priority = 1000` and `lazy = false` on your colorscheme plugin. + +See |diffs-config| for customization, |diffs-integrations| for plugin +support. ============================================================================== CONFIGURATION *diffs-config* @@ -82,6 +83,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: fugitive = false, neogit = false, gitsigns = false, + committia = false, extra_filetypes = {}, highlights = { background = true, @@ -175,6 +177,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { gitsigns = true } < + {committia} (boolean|table, default: false) + Enable committia.vim integration. Pass `true` + or `{}` to enable, `false` to disable. When + active, committia's diff pane (`ft=git`, + buffer name `__committia_diff__`) receives + treesitter syntax, line backgrounds, and + intra-line diffs. >lua + vim.g.diffs = { committia = true } +< + {extra_filetypes} (table, default: {}) Additional filetypes to attach to, beyond the built-in `git`, `gitcommit`, and any enabled From dc6fd7a3876dd28a2f213619a7edcd88c0627b3b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:04:21 -0500 Subject: [PATCH 49/55] feat: add committia.vim integration (#166) ## Problem committia.vim's diff pane (`ft=git`, buffer name `__committia_diff__`) is rejected by the `ft=git` guard in the `FileType` callback, preventing diffs.nvim from highlighting it. ## Solution Add a `committia` config toggle following the same pattern as `neogit`/`gitsigns`. When enabled, the `ft=git` guard also allows committia's `__committia_diff__` buffer through. Closes #161 --- lua/diffs/init.lua | 17 +++++++++++++++++ plugin/diffs.lua | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 06b6ad9..a9551a5 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -40,6 +40,8 @@ ---@class diffs.GitsignsConfig +---@class diffs.CommittiaConfig + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -65,6 +67,7 @@ ---@field fugitive diffs.FugitiveConfig|false ---@field neogit diffs.NeogitConfig|false ---@field gitsigns diffs.GitsignsConfig|false +---@field committia diffs.CommittiaConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -145,6 +148,7 @@ local default_config = { fugitive = false, neogit = false, gitsigns = false, + committia = false, conflict = { enabled = true, disable_diagnostics = true, @@ -599,6 +603,10 @@ local function init() opts.gitsigns = {} end + if opts.committia == true then + opts.committia = {} + end + vim.validate('debug', opts.debug, function(v) return v == nil or type(v) == 'boolean' or type(v) == 'string' end, 'boolean or string (file path)') @@ -612,6 +620,9 @@ local function init() vim.validate('gitsigns', opts.gitsigns, function(v) return v == nil or v == false or type(v) == 'table' end, 'table or false') + vim.validate('committia', opts.committia, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) vim.validate('highlights', opts.highlights, 'table', true) @@ -995,6 +1006,12 @@ function M.get_fugitive_config() return config.fugitive end +---@return diffs.CommittiaConfig|false +function M.get_committia_config() + init() + return config.committia +end + ---@return diffs.ConflictConfig function M.get_conflict_config() init() diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 43c5564..bce599d 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -23,7 +23,10 @@ vim.api.nvim_create_autocmd('FileType', { callback = function(args) local diffs = require('diffs') if args.match == 'git' then - if not diffs.get_fugitive_config() or not diffs.is_fugitive_buffer(args.buf) then + local is_fugitive = diffs.get_fugitive_config() and diffs.is_fugitive_buffer(args.buf) + local is_committia = diffs.get_committia_config() + and vim.api.nvim_buf_get_name(args.buf):match('__committia_diff__$') + if not is_fugitive and not is_committia then return end end From d584d816bf1242049ee93788893dd5b3c05d63f7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:46:15 -0500 Subject: [PATCH 50/55] feat: add telescope.nvim integration (#170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #169. ## Problem Telescope never sets `filetype=diff` on preview buffers — it calls `vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's `FileType` autocmd never fires. ## Solution Add a `telescope` config toggle (same pattern as neogit/gitsigns/committia) and a `User TelescopePreviewerLoaded` autocmd that calls `attach()` on the preview buffer. Disabled by default; enable with `telescope = true`. Also adds a `diffs-telescope` vimdoc section documenting the integration and the upstream first-line preview bug (nvim-telescope/telescope.nvim#3626). Includes committia.vim integration from `feat/committia`. --- doc/diffs.nvim.txt | 34 ++++++++++++++++++++++++++++++++++ lua/diffs/init.lua | 17 +++++++++++++++++ plugin/diffs.lua | 10 ++++++++++ 3 files changed, 61 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 85b8cb5..70f7455 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -41,6 +41,7 @@ CONTENTS *diffs-contents* Fugitive .......................................... |diffs-fugitive| Neogit .............................................. |diffs-neogit| Gitsigns .......................................... |diffs-gitsigns| + Telescope ........................................ |diffs-telescope| 8. Conflict Resolution .................................... |diffs-conflict| 9. Merge Diff Resolution ..................................... |diffs-merge| 10. API ......................................................... |diffs-api| @@ -84,6 +85,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: neogit = false, gitsigns = false, committia = false, + telescope = false, extra_filetypes = {}, highlights = { background = true, @@ -187,6 +189,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { committia = true } < + {telescope} (boolean|table, default: false) + Enable telescope.nvim preview highlighting. + Pass `true` or `{}` to enable, `false` to + disable. When active, telescope preview + buffers showing diffs receive treesitter + syntax, line backgrounds, and intra-line + diffs. See |diffs-telescope|. >lua + vim.g.diffs = { telescope = true } +< + {extra_filetypes} (table, default: {}) Additional filetypes to attach to, beyond the built-in `git`, `gitcommit`, and any enabled @@ -577,6 +589,28 @@ backgrounds, and intra-line diffs. Highlights are applied in a separate `diffs-gitsigns` namespace and do not interfere with the main decoration provider used for diff buffers. +------------------------------------------------------------------------------ +TELESCOPE *diffs-telescope* + +Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim) +preview highlighting: >lua + vim.g.diffs = { telescope = true } +< + +Telescope does not set `filetype=diff` on preview buffers — it calls +`vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's `FileType` +autocmd never fires. This integration listens for the +`User TelescopePreviewerLoaded` event and attaches to the preview buffer. + +Pickers that show diff content (e.g. `git_bcommits`, `git_status`) will +receive treesitter syntax, line backgrounds, and intra-line diffs in the +preview pane. + +Known issue: Telescope's previewer may render the first line of the preview +buffer with a black background regardless of colorscheme. This is a +Telescope artifact unrelated to diffs.nvim. Tracked upstream: +https://github.com/nvim-telescope/telescope.nvim/issues/3626 + ============================================================================== CONFLICT RESOLUTION *diffs-conflict* diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index a9551a5..882d0c4 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -42,6 +42,8 @@ ---@class diffs.CommittiaConfig +---@class diffs.TelescopeConfig + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -68,6 +70,7 @@ ---@field neogit diffs.NeogitConfig|false ---@field gitsigns diffs.GitsignsConfig|false ---@field committia diffs.CommittiaConfig|false +---@field telescope diffs.TelescopeConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -149,6 +152,7 @@ local default_config = { neogit = false, gitsigns = false, committia = false, + telescope = false, conflict = { enabled = true, disable_diagnostics = true, @@ -607,6 +611,10 @@ local function init() opts.committia = {} end + if opts.telescope == true then + opts.telescope = {} + end + vim.validate('debug', opts.debug, function(v) return v == nil or type(v) == 'boolean' or type(v) == 'string' end, 'boolean or string (file path)') @@ -623,6 +631,9 @@ local function init() vim.validate('committia', opts.committia, function(v) return v == nil or v == false or type(v) == 'table' end, 'table or false') + vim.validate('telescope', opts.telescope, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) vim.validate('highlights', opts.highlights, 'table', true) @@ -1012,6 +1023,12 @@ function M.get_committia_config() return config.committia end +---@return diffs.TelescopeConfig|false +function M.get_telescope_config() + init() + return config.telescope +end + ---@return diffs.ConflictConfig function M.get_conflict_config() init() diff --git a/plugin/diffs.lua b/plugin/diffs.lua index bce599d..9f59dc1 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -18,6 +18,16 @@ if gs_cfg == true or type(gs_cfg) == 'table' then end end +local tel_cfg = (vim.g.diffs or {}).telescope +if tel_cfg == true or type(tel_cfg) == 'table' then + vim.api.nvim_create_autocmd('User', { + pattern = 'TelescopePreviewerLoaded', + callback = function() + require('diffs').attach(vim.api.nvim_get_current_buf()) + end, + }) +end + vim.api.nvim_create_autocmd('FileType', { pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), callback = function(args) From 823743192aac5191818f16ddbe82abcf36ac403e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:12:20 -0500 Subject: [PATCH 51/55] fix(test): use valid hex hash in combined diff fixtures (#171) ## Problem Combined diff test fixtures used `ghi9012` as a result hash, but `g`, `h`, `i` are not hex digits (`%x` matches `[0-9a-fA-F]`). The `%x+` pattern in `highlight.lua` correctly rejected this, so the manual `@constant.diff` extmark for the result hash was never set. The existing assertion passed anyway because the diff grammar's captures on parent hashes (`abc1234`, `def5678`) satisfied the row-level check. ## Solution Replace `ghi9012` with valid hex `a6b9012` in all fixtures. Tighten the result hash assertion to verify exact column range (cols 23-30) so it cannot be satisfied by parent hash captures. Closes #168 --- spec/highlight_spec.lua | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 39cc23c..c6e1a2a 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1654,7 +1654,7 @@ describe('highlight', function() it('applies DiffsClear to headers for combined diffs', function() local bufnr = create_buffer({ 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', '@@@ -1,2 -1,2 +1,3 @@@', @@ -1671,7 +1671,7 @@ describe('highlight', function() header_start_line = 1, header_lines = { 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', }, @@ -1755,7 +1755,7 @@ describe('highlight', function() it('applies header diff grammar at syntax priority for combined diffs', function() local bufnr = create_buffer({ 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', '@@@ -1,2 -1,2 +1,3 @@@', @@ -1772,7 +1772,7 @@ describe('highlight', function() header_start_line = 1, header_lines = { 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', }, @@ -1802,7 +1802,7 @@ describe('highlight', function() it('@diff.minus wins over @punctuation.special on combined diff headers', function() local bufnr = create_buffer({ 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', '@@@ -1,2 -1,2 +1,3 @@@', @@ -1819,7 +1819,7 @@ describe('highlight', function() header_start_line = 1, header_lines = { 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', }, @@ -1862,7 +1862,7 @@ describe('highlight', function() it('applies @keyword.diff on index word for combined diffs', function() local bufnr = create_buffer({ 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', '@@@ -1,2 -1,2 +1,3 @@@', @@ -1879,7 +1879,7 @@ describe('highlight', function() header_start_line = 1, header_lines = { 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', }, @@ -1908,7 +1908,7 @@ describe('highlight', function() it('applies @constant.diff on result hash for combined diffs', function() local bufnr = create_buffer({ 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', '@@@ -1,2 -1,2 +1,3 @@@', @@ -1925,7 +1925,7 @@ describe('highlight', function() header_start_line = 1, header_lines = { 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..ghi9012', + 'index abc1234,def5678..a6b9012', '--- a/lua/merge/target.lua', '+++ b/lua/merge/target.lua', }, @@ -1934,14 +1934,21 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local has_constant = false + local has_result_hash = false for _, mark in ipairs(extmarks) do local d = mark[4] - if mark[2] == 1 and d and d.hl_group == '@constant.diff' and (d.priority or 0) >= 199 then - has_constant = true + if + mark[2] == 1 + and mark[3] == 23 + and d + and d.hl_group == '@constant.diff' + and d.end_col == 30 + and (d.priority or 0) >= 199 + then + has_result_hash = true end end - assert.is_true(has_constant, '@constant.diff on result hash') + assert.is_true(has_result_hash, '@constant.diff on result hash at cols 23-30') delete_buffer(bufnr) end) end) From a880261988c27bdb6f8dea1dfc670c58a9c1515f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:46:02 -0500 Subject: [PATCH 52/55] refactor(config): nest integration toggles under `integrations` namespace (#174) ## Problem Integration keys (`fugitive`, `neogit`, `gitsigns`, `committia`, `telescope`) live at the top level of `vim.g.diffs`, cluttering the config namespace. ## Solution Move them under `vim.g.diffs.integrations.*`. Old top-level keys still work but emit `vim.deprecate` targeting v0.3.2. `compute_filetypes` and `plugin/diffs.lua` fall back to legacy keys for pre-`init()` callers. Also includes `83c17ac` which fixes an invalid hex hash in the combined diff test fixture. --- lua/diffs/init.lua | 148 +++++++++++++++++++++---------- plugin/diffs.lua | 14 ++- spec/init_spec.lua | 54 +++++++---- spec/neogit_integration_spec.lua | 2 +- 4 files changed, 151 insertions(+), 67 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 882d0c4..048b669 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -61,16 +61,24 @@ ---@field priority integer ---@field keymaps diffs.ConflictKeymaps ----@class diffs.Config ----@field debug boolean|string ----@field hide_prefix boolean ----@field extra_filetypes string[] ----@field highlights diffs.Highlights +---@class diffs.IntegrationsConfig ---@field fugitive diffs.FugitiveConfig|false ---@field neogit diffs.NeogitConfig|false ---@field gitsigns diffs.GitsignsConfig|false ---@field committia diffs.CommittiaConfig|false ---@field telescope diffs.TelescopeConfig|false + +---@class diffs.Config +---@field debug boolean|string +---@field hide_prefix boolean +---@field extra_filetypes string[] +---@field highlights diffs.Highlights +---@field integrations diffs.IntegrationsConfig +---@field fugitive? diffs.FugitiveConfig|false deprecated: use integrations.fugitive +---@field neogit? diffs.NeogitConfig|false deprecated: use integrations.neogit +---@field gitsigns? diffs.GitsignsConfig|false deprecated: use integrations.gitsigns +---@field committia? diffs.CommittiaConfig|false deprecated: use integrations.committia +---@field telescope? diffs.TelescopeConfig|false deprecated: use integrations.telescope ---@field conflict diffs.ConflictConfig ---@class diffs @@ -148,11 +156,13 @@ local default_config = { char_bg = 201, }, }, - fugitive = false, - neogit = false, - gitsigns = false, - committia = false, - telescope = false, + integrations = { + fugitive = false, + neogit = false, + gitsigns = false, + committia = false, + telescope = false, + }, conflict = { enabled = true, disable_diagnostics = true, @@ -209,11 +219,18 @@ end ---@return string[] function M.compute_filetypes(opts) local fts = { 'git', 'gitcommit' } - local fug = opts.fugitive + local intg = opts.integrations or {} + local fug = intg.fugitive + if fug == nil then + fug = opts.fugitive + end if fug == true or type(fug) == 'table' then table.insert(fts, 'fugitive') end - local neo = opts.neogit + local neo = intg.neogit + if neo == nil then + neo = opts.neogit + end if neo == true or type(neo) == 'table' then table.insert(fts, 'NeogitStatus') table.insert(fts, 'NeogitCommitView') @@ -584,6 +601,48 @@ local function compute_highlight_groups() end end +local integration_keys = { 'fugitive', 'neogit', 'gitsigns', 'committia', 'telescope' } + +local function migrate_integrations(opts) + if opts.integrations then + local stale = {} + for _, key in ipairs(integration_keys) do + if opts[key] ~= nil then + stale[#stale + 1] = key + opts[key] = nil + end + end + if #stale > 0 then + local old = 'vim.g.diffs.{' .. table.concat(stale, ', ') .. '}' + local new = 'vim.g.diffs.integrations.{' .. table.concat(stale, ', ') .. '}' + vim.notify( + '[diffs.nvim] ignoring ' .. old .. '; move to ' .. new .. ' or remove', + vim.log.levels.WARN + ) + end + return + end + local has_legacy = false + for _, key in ipairs(integration_keys) do + if opts[key] ~= nil then + has_legacy = true + break + end + end + if not has_legacy then + return + end + vim.deprecate('vim.g.diffs.', 'vim.g.diffs.integrations.*', '0.3.2', 'diffs.nvim') + local legacy = {} + for _, key in ipairs(integration_keys) do + if opts[key] ~= nil then + legacy[key] = opts[key] + opts[key] = nil + end + end + opts.integrations = legacy +end + local function init() if initialized then return @@ -592,48 +651,45 @@ local function init() local opts = vim.g.diffs or {} + migrate_integrations(opts) + + local intg = opts.integrations or {} local fugitive_defaults = { horizontal = 'du', vertical = 'dU' } - if opts.fugitive == true then - opts.fugitive = vim.deepcopy(fugitive_defaults) - elseif type(opts.fugitive) == 'table' then - opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults) + if intg.fugitive == true then + intg.fugitive = vim.deepcopy(fugitive_defaults) + elseif type(intg.fugitive) == 'table' then + intg.fugitive = vim.tbl_extend('keep', intg.fugitive, fugitive_defaults) end - if opts.neogit == true then - opts.neogit = {} + if intg.neogit == true then + intg.neogit = {} end - if opts.gitsigns == true then - opts.gitsigns = {} + if intg.gitsigns == true then + intg.gitsigns = {} end - if opts.committia == true then - opts.committia = {} + if intg.committia == true then + intg.committia = {} end - if opts.telescope == true then - opts.telescope = {} + if intg.telescope == true then + intg.telescope = {} end + opts.integrations = intg + vim.validate('debug', opts.debug, function(v) return v == nil or type(v) == 'boolean' or type(v) == 'string' end, 'boolean or string (file path)') vim.validate('hide_prefix', opts.hide_prefix, 'boolean', true) - vim.validate('fugitive', opts.fugitive, function(v) + vim.validate('integrations', opts.integrations, 'table', true) + local integration_validator = function(v) return v == nil or v == false or type(v) == 'table' - end, 'table or false') - vim.validate('neogit', opts.neogit, function(v) - return v == nil or v == false or type(v) == 'table' - end, 'table or false') - vim.validate('gitsigns', opts.gitsigns, function(v) - return v == nil or v == false or type(v) == 'table' - end, 'table or false') - vim.validate('committia', opts.committia, function(v) - return v == nil or v == false or type(v) == 'table' - end, 'table or false') - vim.validate('telescope', opts.telescope, function(v) - return v == nil or v == false or type(v) == 'table' - end, 'table or false') + end + for _, key in ipairs(integration_keys) do + vim.validate('integrations.' .. key, intg[key], integration_validator, 'table or false') + end vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) vim.validate('highlights', opts.highlights, 'table', true) @@ -704,13 +760,13 @@ local function init() end end - if type(opts.fugitive) == 'table' then + if type(intg.fugitive) == 'table' then ---@type diffs.FugitiveConfig - local fug = opts.fugitive - vim.validate('fugitive.horizontal', fug.horizontal, function(v) + local fug = intg.fugitive + vim.validate('integrations.fugitive.horizontal', fug.horizontal, function(v) return v == nil or v == false or type(v) == 'string' end, 'string or false') - vim.validate('fugitive.vertical', fug.vertical, function(v) + vim.validate('integrations.fugitive.vertical', fug.vertical, function(v) return v == nil or v == false or type(v) == 'string' end, 'string or false') end @@ -934,7 +990,7 @@ function M.attach(bufnr) attached_buffers[bufnr] = true local neogit_augroup = nil - if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then + if config.integrations.neogit and vim.bo[bufnr].filetype:match('^Neogit') then vim.b[bufnr].neogit_disable_hunk_highlight = true neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) vim.api.nvim_create_autocmd('User', { @@ -1014,19 +1070,19 @@ end ---@return diffs.FugitiveConfig|false function M.get_fugitive_config() init() - return config.fugitive + return config.integrations.fugitive end ---@return diffs.CommittiaConfig|false function M.get_committia_config() init() - return config.committia + return config.integrations.committia end ---@return diffs.TelescopeConfig|false function M.get_telescope_config() init() - return config.telescope + return config.integrations.telescope end ---@return diffs.ConflictConfig diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 9f59dc1..2b785ef 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -5,7 +5,17 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() -local gs_cfg = (vim.g.diffs or {}).gitsigns +local function get_raw_integration(key) + local user = vim.g.diffs or {} + local intg = user.integrations or {} + local v = intg[key] + if v ~= nil then + return v + end + return user[key] +end + +local gs_cfg = get_raw_integration('gitsigns') if gs_cfg == true or type(gs_cfg) == 'table' then if not require('diffs.gitsigns').setup() then vim.api.nvim_create_autocmd('User', { @@ -18,7 +28,7 @@ if gs_cfg == true or type(gs_cfg) == 'table' then end end -local tel_cfg = (vim.g.diffs or {}).telescope +local tel_cfg = get_raw_integration('telescope') if tel_cfg == true or type(tel_cfg) == 'table' then vim.api.nvim_create_autocmd('User', { pattern = 'TelescopePreviewerLoaded', diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 2e1952e..5b564ae 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -336,45 +336,45 @@ describe('diffs', function() assert.are.same({ 'git', 'gitcommit' }, fts) end) - it('includes fugitive when fugitive = true', function() - local fts = compute({ fugitive = true }) + it('includes fugitive when integrations.fugitive = true', function() + local fts = compute({ integrations = { fugitive = true } }) assert.is_true(vim.tbl_contains(fts, 'fugitive')) end) - it('includes fugitive when fugitive is a table', function() - local fts = compute({ fugitive = { horizontal = 'dd' } }) + it('includes fugitive when integrations.fugitive is a table', function() + local fts = compute({ integrations = { fugitive = { horizontal = 'dd' } } }) assert.is_true(vim.tbl_contains(fts, 'fugitive')) end) - it('excludes fugitive when fugitive = false', function() - local fts = compute({ fugitive = false }) + it('excludes fugitive when integrations.fugitive = false', function() + local fts = compute({ integrations = { fugitive = false } }) assert.is_false(vim.tbl_contains(fts, 'fugitive')) end) - it('excludes fugitive when fugitive is nil', function() - local fts = compute({}) + it('excludes fugitive when integrations.fugitive is nil', function() + local fts = compute({ integrations = {} }) assert.is_false(vim.tbl_contains(fts, 'fugitive')) end) - it('includes neogit filetypes when neogit = true', function() - local fts = compute({ neogit = true }) + it('includes neogit filetypes when integrations.neogit = true', function() + local fts = compute({ integrations = { neogit = true } }) assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) assert.is_true(vim.tbl_contains(fts, 'NeogitCommitView')) assert.is_true(vim.tbl_contains(fts, 'NeogitDiffView')) end) - it('includes neogit filetypes when neogit is a table', function() - local fts = compute({ neogit = {} }) + it('includes neogit filetypes when integrations.neogit is a table', function() + local fts = compute({ integrations = { neogit = {} } }) assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) end) - it('excludes neogit when neogit = false', function() - local fts = compute({ neogit = false }) + it('excludes neogit when integrations.neogit = false', function() + local fts = compute({ integrations = { neogit = false } }) assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) end) - it('excludes neogit when neogit is nil', function() - local fts = compute({}) + it('excludes neogit when integrations.neogit is nil', function() + local fts = compute({ integrations = {} }) assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) end) @@ -383,13 +383,31 @@ describe('diffs', function() assert.is_true(vim.tbl_contains(fts, 'diff')) end) - it('combines fugitive, neogit, and extra_filetypes', function() - local fts = compute({ fugitive = true, neogit = true, extra_filetypes = { 'diff' } }) + it('combines integrations and extra_filetypes', function() + local fts = compute({ + integrations = { fugitive = true, neogit = true }, + extra_filetypes = { 'diff' }, + }) assert.is_true(vim.tbl_contains(fts, 'git')) assert.is_true(vim.tbl_contains(fts, 'fugitive')) assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) assert.is_true(vim.tbl_contains(fts, 'diff')) end) + + it('falls back to legacy top-level fugitive key', function() + local fts = compute({ fugitive = true }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('falls back to legacy top-level neogit key', function() + local fts = compute({ neogit = true }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('prefers integrations key over legacy top-level key', function() + local fts = compute({ integrations = { fugitive = false }, fugitive = true }) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) end) describe('diff mode', function() diff --git a/spec/neogit_integration_spec.lua b/spec/neogit_integration_spec.lua index ef33049..958df7f 100644 --- a/spec/neogit_integration_spec.lua +++ b/spec/neogit_integration_spec.lua @@ -1,6 +1,6 @@ require('spec.helpers') -vim.g.diffs = { neogit = true } +vim.g.diffs = { integrations = { neogit = true } } local diffs = require('diffs') local parser = require('diffs.parser') From 595c35d9101dae729ab25ca42dcfd5bbfe2a65f1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 14:51:10 -0500 Subject: [PATCH 53/55] doc: update integration spec --- doc/diffs.nvim.txt | 113 ++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 70f7455..9220c3e 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -81,11 +81,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { debug = false, hide_prefix = false, - fugitive = false, - neogit = false, - gitsigns = false, - committia = false, - telescope = false, + integrations = { + fugitive = false, + neogit = false, + gitsigns = false, + committia = false, + telescope = false, + }, extra_filetypes = {}, highlights = { background = true, @@ -145,60 +147,63 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: is also enabled, the overlay inherits the line's background color. - {fugitive} (boolean|table, default: false) + {integrations} (table, default: all false) + Integration toggles. Each key accepts `true`, + `false`, or a table with sub-options. Passing + `true` or a table enables the integration; + `false` disables it. See |diffs-integrations|. + *diffs.IntegrationsConfig* + Fields: ~ + + {fugitive} (boolean|table, default: false) Enable vim-fugitive integration. Pass `true` - for defaults, `false` to disable, or a table - with sub-options (see |diffs.FugitiveConfig|). - Passing a table implicitly enables the - integration — no `enabled` field needed. - When active, the `fugitive` filetype is - registered and status buffer keymaps are set. >lua - vim.g.diffs = { fugitive = true } + for defaults, or a table with sub-options + (see |diffs.FugitiveConfig|). When active, + the `fugitive` filetype is registered and + status buffer keymaps are set. >lua vim.g.diffs = { - fugitive = { horizontal = 'dd' }, + integrations = { fugitive = true }, + } + vim.g.diffs = { + integrations = { + fugitive = { horizontal = 'dd' }, + }, } < - {neogit} (boolean|table, default: false) - Enable Neogit integration. Pass `true` or - `{}` to enable, `false` to disable. When - active, `NeogitStatus`, `NeogitCommitView`, - and `NeogitDiffView` filetypes are registered - and Neogit highlight overrides are applied. + {neogit} (boolean|table, default: false) + Enable Neogit integration. When active, + `NeogitStatus`, `NeogitCommitView`, and + `NeogitDiffView` filetypes are registered. See |diffs-neogit|. >lua - vim.g.diffs = { neogit = true } + integrations = { neogit = true } < - {gitsigns} (boolean|table, default: false) + {gitsigns} (boolean|table, default: false) Enable gitsigns.nvim blame popup highlighting. - Pass `true` or `{}` to enable, `false` to - disable. When active, `:Gitsigns blame_line` - popups receive treesitter syntax, line - backgrounds, and intra-line character diffs. See |diffs-gitsigns|. >lua - vim.g.diffs = { gitsigns = true } + integrations = { gitsigns = true } < - {committia} (boolean|table, default: false) - Enable committia.vim integration. Pass `true` - or `{}` to enable, `false` to disable. When - active, committia's diff pane (`ft=git`, - buffer name `__committia_diff__`) receives - treesitter syntax, line backgrounds, and - intra-line diffs. >lua - vim.g.diffs = { committia = true } + {committia} (boolean|table, default: false) + Enable committia.vim integration. When active, + committia's diff pane receives treesitter + syntax and intra-line diffs. >lua + integrations = { committia = true } < - {telescope} (boolean|table, default: false) + {telescope} (boolean|table, default: false) Enable telescope.nvim preview highlighting. - Pass `true` or `{}` to enable, `false` to - disable. When active, telescope preview - buffers showing diffs receive treesitter - syntax, line backgrounds, and intra-line - diffs. See |diffs-telescope|. >lua - vim.g.diffs = { telescope = true } + See |diffs-telescope|. >lua + integrations = { telescope = true } < + Legacy top-level keys (`vim.g.diffs.fugitive`, + etc.) still work but emit a deprecation + warning. If both `integrations` and top-level + keys are present, `integrations` wins and + the stale keys are ignored with a warning. + {extra_filetypes} (table, default: {}) Additional filetypes to attach to, beyond the built-in `git`, `gitcommit`, and any enabled @@ -488,9 +493,11 @@ Enable via config toggles. The plugin registers `FileType` autocmds for each integration's filetypes and attaches automatically. >lua vim.g.diffs = { - fugitive = true, - neogit = true, - gitsigns = true, + integrations = { + fugitive = true, + neogit = true, + gitsigns = true, + }, } < @@ -505,7 +512,7 @@ to attach to any buffer whose content looks like a diff. FUGITIVE *diffs-fugitive* Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua - vim.g.diffs = { fugitive = true } + vim.g.diffs = { integrations = { fugitive = true } } < |:Git| status and commit views receive treesitter syntax, line backgrounds, @@ -546,9 +553,11 @@ Configuration: ~ *diffs.FugitiveConfig* >lua vim.g.diffs = { - fugitive = { - horizontal = 'du', -- keymap for horizontal split, false to disable - vertical = 'dU', -- keymap for vertical split, false to disable + integrations = { + fugitive = { + horizontal = 'du', -- keymap for horizontal split, false to disable + vertical = 'dU', -- keymap for vertical split, false to disable + }, }, } < @@ -568,7 +577,7 @@ Configuration: ~ NEOGIT *diffs-neogit* Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua - vim.g.diffs = { neogit = true } + vim.g.diffs = { integrations = { neogit = true } } < Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status @@ -580,7 +589,7 @@ GITSIGNS *diffs-gitsigns* Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) blame popup highlighting: >lua - vim.g.diffs = { gitsigns = true } + vim.g.diffs = { integrations = { gitsigns = true } } < `:Gitsigns blame_line full=true` popups receive treesitter syntax, line @@ -594,7 +603,7 @@ TELESCOPE *diffs-telescope* Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim) preview highlighting: >lua - vim.g.diffs = { telescope = true } + vim.g.diffs = { integrations = { telescope = true } } < Telescope does not set `filetype=diff` on preview buffers — it calls From 53dd5d6325990d545ecf671b47bb48ff885aef67 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sun, 8 Mar 2026 01:49:26 +0000 Subject: [PATCH 54/55] fix(highlight): handle nil Normal.bg in blending logic (#175) Co-authored-by: Barrett Ruth --- .github/ISSUE_TEMPLATE/bug_report.yaml | 20 ++++++- lua/diffs/init.lua | 8 ++- spec/init_spec.lua | 73 ++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 001e042..798b7a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -68,10 +68,26 @@ body: load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() require('lazy.nvim').setup({ spec = { - 'tpope/vim-fugitive', + { 'barrettruth/midnight.nvim', lazy = false, config = function() vim.cmd.colorscheme('midnight') end }, + { 'tpope/vim-fugitive' }, + { 'NeogitOrg/neogit', dependencies = { 'nvim-lua/plenary.nvim' } }, + { 'lewis6991/gitsigns.nvim', config = true }, + { 'rhysd/committia.vim' }, + { 'nvim-telescope/telescope.nvim', dependencies = { 'nvim-lua/plenary.nvim' } }, { 'barrettruth/diffs.nvim', - opts = {}, + init = function() + vim.g.diffs = { + debug = '/tmp/diffs.log', + integrations = { + fugitive = true, + neogit = true, + gitsigns = true, + committia = true, + telescope = true, + }, + } + end, }, }, }) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 048b669..2f8285c 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -510,7 +510,6 @@ local function compute_highlight_groups() if not normal.bg and not hl_retry_pending then hl_retry_pending = true vim.schedule(function() - hl_retry_pending = false compute_highlight_groups() for bufnr, _ in pairs(attached_buffers) do invalidate_cache(bufnr) @@ -1115,6 +1114,13 @@ M._test = { process_pending_clear = process_pending_clear, ft_retry_pending = ft_retry_pending, compute_hunk_context = compute_hunk_context, + compute_highlight_groups = compute_highlight_groups, + get_hl_retry_pending = function() + return hl_retry_pending + end, + set_hl_retry_pending = function(v) + hl_retry_pending = v + end, } return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5b564ae..09c0f24 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -527,4 +527,77 @@ describe('diffs', function() end) end) end) + + describe('compute_highlight_groups', function() + local saved_get_hl, saved_set_hl, saved_schedule + local set_calls, schedule_cbs + + before_each(function() + saved_get_hl = vim.api.nvim_get_hl + saved_set_hl = vim.api.nvim_set_hl + saved_schedule = vim.schedule + set_calls = {} + schedule_cbs = {} + vim.api.nvim_set_hl = function(_, group, opts) + set_calls[group] = opts + end + vim.schedule = function(cb) + table.insert(schedule_cbs, cb) + end + diffs._test.set_hl_retry_pending(false) + end) + + after_each(function() + vim.api.nvim_get_hl = saved_get_hl + vim.api.nvim_set_hl = saved_set_hl + vim.schedule = saved_schedule + diffs._test.set_hl_retry_pending(false) + end) + + it('sets DiffsClear.bg to a number when Normal.bg is nil', function() + vim.api.nvim_get_hl = function(ns, opts) + if opts.name == 'Normal' then + return { fg = 0xc0c0c0 } + end + return saved_get_hl(ns, opts) + end + diffs._test.compute_highlight_groups() + assert.is_number(set_calls.DiffsClear.bg) + assert.is_table(set_calls.DiffsAdd) + assert.is_table(set_calls.DiffsDelete) + end) + + it('retries once then stops when Normal.bg stays nil', function() + vim.api.nvim_get_hl = function(ns, opts) + if opts.name == 'Normal' then + return { fg = 0xc0c0c0 } + end + return saved_get_hl(ns, opts) + end + diffs._test.compute_highlight_groups() + assert.are.equal(1, #schedule_cbs) + schedule_cbs[1]() + assert.are.equal(1, #schedule_cbs) + assert.is_true(diffs._test.get_hl_retry_pending()) + end) + + it('picks up bg on retry when colorscheme loads late', function() + local call_count = 0 + vim.api.nvim_get_hl = function(ns, opts) + if opts.name == 'Normal' then + call_count = call_count + 1 + if call_count <= 1 then + return { fg = 0xc0c0c0 } + end + return { fg = 0xc0c0c0, bg = 0x1e1e2e } + end + return saved_get_hl(ns, opts) + end + diffs._test.compute_highlight_groups() + assert.are.equal(1, #schedule_cbs) + schedule_cbs[1]() + assert.is_number(set_calls.DiffsClear.bg) + assert.are.equal(1, #schedule_cbs) + end) + end) end) From 55c26584190d9135d372f71158a413826c167c1e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 20:50:24 -0500 Subject: [PATCH 55/55] docs: add @tris203 to acknowledgements --- README.md | 1 + doc/diffs.nvim.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 0051f21..b7bb86c 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,4 @@ See the documentation for more information. filetype fix, shebang/modeline detection, treesitter injection support, decoration provider highlighting architecture, gitsigns blame popup highlighting +- [@tris203](https://github.com/tris203) - support for transparent backgrounds diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 9220c3e..73f7cc3 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -993,6 +993,7 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements* - @phanen (https://github.com/phanen) - diff header highlighting, treesitter injection support, blame_hl.nvim (gitsigns blame popup highlighting inspiration) +- @tris203 (https://github.com/tris203) - support for transparent backgrounds ============================================================================== vim:tw=78:ts=8:ft=help:norl: