diff --git a/README.md b/README.md index 89254ac..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ syntax highlighting. - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) - Character-level (intra-line) diff highlighting for changed characters -- Configurable debouncing, max lines, and diff prefix concealment +- Inline merge conflict detection, highlighting, and resolution keymaps +- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and + highlight overrides ## Requirements @@ -41,12 +43,11 @@ luarocks install diffs.nvim ## Known Limitations -- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation - without surrounding code context. When a hunk shows lines added to an existing - block (e.g., adding a plugin inside `return { ... }`), the parser doesn't see - the `return` statement and may produce incorrect highlighting. This is - inherent to parsing code fragments—no diff tooling solves this without - significant complexity. +- **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. - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily @@ -63,12 +64,17 @@ luarocks install diffs.nvim compatible, but both plugins modifying line highlights may produce unexpected results - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - - conflict marker highlighting may overlap with `diffs.nvim` + `diffs.nvim` now includes built-in conflict resolution; disable one or the + other to avoid overlap # Acknowledgements - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) +- [`difftastic`](https://github.com/Wilfred/difftastic) +- [`mini.diff`](https://github.com/echasnovski/mini.diff) +- [`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 + filetype fix, shebang/modeline detection, treesitter injection support diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 9554059..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -20,6 +20,7 @@ Features: ~ - Blended diff background colors that preserve syntax visibility - Optional diff prefix (`+`/`-`/` `) concealment - Gutter (line number) highlighting +- Inline merge conflict marker detection, highlighting, and resolution ============================================================================== REQUIREMENTS *diffs-requirements* @@ -56,24 +57,43 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: highlights = { background = true, gutter = true, + blend_alpha = 0.6, + context = { + enabled = true, + lines = 25, + }, treesitter = { enabled = true, max_lines = 500, }, vim = { enabled = false, - max_lines = 500, + max_lines = 200, }, intra = { enabled = true, algorithm = 'default', max_lines = 500, }, + overrides = {}, }, fugitive = { horizontal = 'du', vertical = 'dU', }, + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, } < *diffs.Config* @@ -102,6 +122,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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. + *diffs.Highlights* Highlights table fields: ~ {background} (boolean, default: true) @@ -113,6 +137,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Highlight line numbers with matching colors. Only visible if line numbers are enabled. + {blend_alpha} (number, default: 0.6) + Alpha value for character-level blend intensity. + Controls how strongly changed characters stand + out from the line-level background. Must be + between 0 and 1 (inclusive). Higher values + produce more vivid character-level highlights. + + {context} (table, default: see below) + Syntax parsing context options. + See |diffs.ContextConfig| for fields. + {treesitter} (table, default: see below) Treesitter highlighting options. See |diffs.TreesitterConfig| for fields. @@ -125,6 +160,27 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. + {overrides} (table, default: {}) + Map of highlight group names to highlight + definitions (see |nvim_set_hl()|). Applied + after all computed groups without `default`, + so overrides always win over both computed + defaults and colorscheme definitions. + + *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. + + {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. + *diffs.TreesitterConfig* Treesitter config fields: ~ {enabled} (boolean, default: true) @@ -188,6 +244,9 @@ COMMANDS *diffs-commands* code language, plus diff header highlighting for `diff --git`, `---`, `+++`, and `@@` lines. + If a `diffs://` window already exists in the current tabpage, the new + diff replaces its buffer instead of creating another split. + Parameters: ~ {revision} (string, optional) Git revision to diff against. Defaults to HEAD. @@ -205,6 +264,63 @@ COMMANDS *diffs-commands* :Ghdiff [revision] *:Ghdiff* Like |:Gdiff| but explicitly opens in a horizontal split. +============================================================================== +MAPPINGS *diffs-mappings* + + *(diffs-gdiff)* +(diffs-gdiff) Show unified diff against HEAD in a horizontal + split. Equivalent to |:Gdiff| with no arguments. + + *(diffs-gvdiff)* +(diffs-gvdiff) Show unified diff against HEAD in a vertical + split. Equivalent to |:Gvdiff| with no arguments. + +Example configuration: >lua + vim.keymap.set('n', 'gd', '(diffs-gdiff)') + 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://` + buffers created by |:Gdiff|, |:Gvdiff|, |:Ghdiff|, + or the fugitive status keymaps. + ============================================================================== FUGITIVE STATUS KEYMAPS *diffs-fugitive* @@ -254,6 +370,94 @@ Configuration: ~ Keymap for unified diff in vertical split. Set to `false` to disable. +============================================================================== +CONFLICT RESOLUTION *diffs-conflict* + +diffs.nvim detects inline merge conflict markers (`<<<<<<<`/`=======`/ +`>>>>>>>`) in working files and provides highlighting and resolution keymaps. +Both standard and diff3 (`|||||||`) formats are supported. + +Conflict regions are detected automatically on `BufReadPost` and re-scanned +on `TextChanged`. When all conflicts in a buffer are resolved, highlighting +is removed and diagnostics are re-enabled. + +Configuration: ~ + *diffs.ConflictConfig* +>lua + vim.g.diffs = { + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, + } +< + Fields: ~ + {enabled} (boolean, default: true) + Enable conflict marker detection and + resolution. Set to `false` to disable + entirely. + + {disable_diagnostics} (boolean, default: true) + Suppress LSP diagnostics on buffers with + conflict markers. Markers produce syntax + errors that clutter the diagnostic list. + Diagnostics are re-enabled when all conflicts + are resolved. Set `false` to leave + diagnostics alone. + + {show_virtual_text} (boolean, default: true) + Show virtual text labels (" current" and + " incoming") at the end of `<<<<<<<` and + `>>>>>>>` marker lines. + + {keymaps} (table, default: see above) + Buffer-local keymaps for conflict resolution + and navigation. Each value accepts a string + (custom key) or `false` (disabled). + + *diffs.ConflictKeymaps* + Keymap fields: ~ + {ours} (string|false, default: 'doo') + Accept current (ours) change. + + {theirs} (string|false, default: 'dot') + Accept incoming (theirs) change. + + {both} (string|false, default: 'dob') + Accept both changes (ours then theirs). + + {none} (string|false, default: 'don') + Reject both changes (delete entire block). + + {next} (string|false, default: ']x') + Jump to next conflict marker. Wraps around. + + {prev} (string|false, default: '[x') + Jump to previous conflict marker. Wraps + around. + +User events: ~ + *DiffsConflictResolved* + DiffsConflictResolved Fired when the last conflict in a buffer is + resolved. Useful for triggering custom actions + (e.g., auto-staging the file). >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'DiffsConflictResolved', + callback = function() + print('all conflicts resolved!') + end, + }) +< + ============================================================================== API *diffs-api* @@ -305,14 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation without surrounding code -context. When a hunk shows lines added to an existing block (e.g., adding a -plugin inside `return { ... }`), the parser doesn't see the `return` -statement and may produce incorrect or unusual highlighting. +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. -This is inherent to parsing code fragments. No diff tooling solves this -problem without significant complexity—the parser simply doesn't have enough -information to understand the full syntactic structure. +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. Syntax Highlighting Flash ~ *diffs-flash* @@ -350,8 +555,9 @@ conflict: modifying line highlights may produce unexpected results. - git-conflict.nvim (akinsho/git-conflict.nvim) - Provides conflict marker highlighting that may overlap with - diffs.nvim's highlighting in conflict scenarios. + Provides conflict marker highlighting and resolution keymaps. + diffs.nvim now has built-in conflict resolution (see + |diffs-conflict|). Disable one or the other to avoid overlap. If you experience visual conflicts, try disabling the conflicting plugin's diff-related features. @@ -359,9 +565,15 @@ diff-related features. ============================================================================== HIGHLIGHT GROUPS *diffs-highlights* -diffs.nvim defines custom highlight groups. Fugitive unified diff groups use -`default = true`, so colorschemes can override them. Diff mode groups are -always derived from the corresponding `Diff*` groups. +diffs.nvim defines custom highlight groups. All groups use `default = true`, +so colorschemes can override them by defining the group before the plugin +loads. + +All derived groups are computed by alpha-blending a source color into the +`Normal` background. Line-level groups blend at 40% alpha for a subtle tint; +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* @@ -374,23 +586,54 @@ Fugitive unified diff highlights: ~ *DiffsAddNr* DiffsAddNr Line number for `+` lines. Foreground from - `diffAdded`, background from `DiffsAdd`. + `DiffsAddText`, background from `DiffsAdd`. *DiffsDeleteNr* DiffsDeleteNr Line number for `-` lines. Foreground from - `diffRemoved`, background from `DiffsDelete`. + `DiffsDeleteText`, background from `DiffsDelete`. *DiffsAddText* DiffsAddText Character-level background for changed characters within `+` lines. Derived by blending `diffAdded` - foreground with `Normal` background at 70% alpha. + foreground with `Normal` background at 60% alpha. Only sets `bg`, so treesitter foreground colors show through. *DiffsDeleteText* DiffsDeleteText Character-level background for changed characters within `-` lines. Derived by blending `diffRemoved` - foreground with `Normal` background at 70% alpha. + foreground with `Normal` background at 60% alpha. + +Conflict highlights: ~ + *DiffsConflictOurs* + DiffsConflictOurs Background for "ours" (current) content lines. + Derived by blending `DiffAdd` background with + `Normal` at 40% alpha (green tint). + + *DiffsConflictTheirs* + DiffsConflictTheirs Background for "theirs" (incoming) content lines. + Derived by blending `DiffChange` background with + `Normal` at 40% alpha. + + *DiffsConflictBase* + DiffsConflictBase Background for base (ancestor) content lines in + diff3 conflicts. Derived by blending `DiffText` + background with `Normal` at 30% alpha (muted). + + *DiffsConflictMarker* + DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`, + `=======`, `>>>>>>>`, and `|||||||` marker lines. + + *DiffsConflictOursNr* + DiffsConflictOursNr Line number for "ours" content lines. Foreground + from higher-alpha blend, background from line-level + blend. + + *DiffsConflictTheirsNr* + DiffsConflictTheirsNr Line number for "theirs" content lines. + + *DiffsConflictBaseNr* + DiffsConflictBaseNr Line number for base content lines (diff3). Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. @@ -416,6 +659,16 @@ To customize these in your colorscheme: >lua vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' }) vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' }) < +Or via `highlights.overrides` in config: >lua + vim.g.diffs = { + highlights = { + overrides = { + DiffsAdd = { bg = '#2e4a3a' }, + DiffsDiffDelete = { link = 'DiffDelete' }, + }, + }, + } +< ============================================================================== HEALTH CHECK *diffs-health* @@ -433,7 +686,8 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements* - vim-fugitive (https://github.com/tpope/vim-fugitive) - codediff.nvim (https://github.com/esmuellert/codediff.nvim) - diffview.nvim (https://github.com/sindrets/diffview.nvim) -- @phanen (https://github.com/phanen) - diff header highlighting +- @phanen (https://github.com/phanen) - diff header highlighting, + treesitter injection support ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 4fc795b..dc6cfe2 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -3,6 +3,27 @@ local M = {} local git = require('diffs.git') local dbg = require('diffs.log').dbg +---@return integer? +function M.find_diffs_window() + local tabpage = vim.api.nvim_get_current_tabpage() + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do + if vim.api.nvim_win_is_valid(win) then + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + if name:match('^diffs://') then + return win + end + end + end + return nil +end + +---@param bufnr integer +function M.setup_diff_buf(bufnr) + vim.diagnostic.enable(false, { bufnr = bufnr }) + vim.keymap.set('n', 'q', 'close', { buffer = bufnr }) +end + ---@param diff_lines string[] ---@param hunk_position { hunk_header: string, offset: integer } ---@return integer? @@ -96,9 +117,16 @@ function M.gdiff(revision, vertical) vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) end - vim.cmd(vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end + M.setup_diff_buf(diff_buf) dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision) vim.schedule(function() @@ -190,8 +218,14 @@ function M.gdiff_file(filepath, opts) vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path) end - vim.cmd(opts.vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end if opts.hunk_position then local target_line = M.find_hunk_line(diff_lines, opts.hunk_position) @@ -201,6 +235,7 @@ function M.gdiff_file(filepath, opts) end end + M.setup_diff_buf(diff_buf) dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -244,9 +279,16 @@ function M.gdiff_section(repo_root, opts) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all') vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) - vim.cmd(opts.vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end + M.setup_diff_buf(diff_buf) dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) vim.schedule(function() diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua new file mode 100644 index 0000000..9e62a15 --- /dev/null +++ b/lua/diffs/conflict.lua @@ -0,0 +1,438 @@ +---@class diffs.ConflictRegion +---@field marker_ours integer +---@field ours_start integer +---@field ours_end integer +---@field marker_base integer? +---@field base_start integer? +---@field base_end integer? +---@field marker_sep integer +---@field theirs_start integer +---@field theirs_end integer +---@field marker_theirs integer + +local M = {} + +local ns = vim.api.nvim_create_namespace('diffs-conflict') + +---@type table +local attached_buffers = {} + +---@type table +local diagnostics_suppressed = {} + +local PRIORITY_LINE_BG = 200 + +---@param lines string[] +---@return diffs.ConflictRegion[] +function M.parse(lines) + local regions = {} + local state = 'idle' + ---@type table? + local current = nil + + for i, line in ipairs(lines) do + local idx = i - 1 + + if state == 'idle' then + if line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + end + elseif state == 'in_ours' then + if line:match('^|||||||') then + current.ours_end = idx + current.marker_base = idx + current.base_start = idx + 1 + state = 'in_base' + elseif line:match('^=======') then + current.ours_end = idx + current.marker_sep = idx + current.theirs_start = idx + 1 + state = 'in_theirs' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + elseif line:match('^>>>>>>>') then + current = nil + state = 'idle' + end + elseif state == 'in_base' then + if line:match('^=======') then + current.base_end = idx + current.marker_sep = idx + current.theirs_start = idx + 1 + state = 'in_theirs' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + elseif line:match('^>>>>>>>') then + current = nil + state = 'idle' + end + elseif state == 'in_theirs' then + if line:match('^>>>>>>>') then + current.theirs_end = idx + current.marker_theirs = idx + table.insert(regions, current) + current = nil + state = 'idle' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + end + end + end + + return regions +end + +---@param bufnr integer +---@return diffs.ConflictRegion[] +local function parse_buffer(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + return M.parse(lines) +end + +---@param bufnr integer +---@param regions diffs.ConflictRegion[] +---@param config diffs.ConflictConfig +local function apply_highlights(bufnr, regions, config) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + for _, region in ipairs(regions) do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + end_row = region.marker_ours + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + 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', + }) + end + + for line = region.ours_start, region.ours_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictOurs', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictOursNr', + priority = PRIORITY_LINE_BG, + }) + end + + if region.marker_base then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_base, 0, { + end_row = region.marker_base + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + for line = region.base_start, region.base_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictBase', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictBaseNr', + priority = PRIORITY_LINE_BG, + }) + end + end + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_sep, 0, { + end_row = region.marker_sep + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + for line = region.theirs_start, region.theirs_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictTheirs', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictTheirsNr', + priority = PRIORITY_LINE_BG, + }) + end + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + end_row = region.marker_theirs + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + 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', + }) + end + end +end + +---@param cursor_line integer +---@param regions diffs.ConflictRegion[] +---@return diffs.ConflictRegion? +local function find_conflict_at_cursor(cursor_line, regions) + for _, region in ipairs(regions) do + if cursor_line >= region.marker_ours and cursor_line <= region.marker_theirs then + return region + end + end + return nil +end + +---@param bufnr integer +---@param region diffs.ConflictRegion +---@param replacement string[] +local function replace_region(bufnr, region, replacement) + vim.api.nvim_buf_set_lines( + bufnr, + region.marker_ours, + region.marker_theirs + 1, + false, + replacement + ) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +local function refresh(bufnr, config) + local regions = parse_buffer(bufnr) + if #regions == 0 then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) + pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = nil + end + vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' }) + return + end + apply_highlights(bufnr, regions, config) + if config.disable_diagnostics and not diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.enable, false, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = true + end +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_ours(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + 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) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_theirs(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + 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) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_both(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + local ours = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) + local theirs = vim.api.nvim_buf_get_lines(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 + replace_region(bufnr, region, combined) + refresh(bufnr, config) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_none(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + replace_region(bufnr, region, {}) + refresh(bufnr, config) +end + +---@param bufnr integer +function M.goto_next(bufnr) + local regions = parse_buffer(bufnr) + if #regions == 0 then + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] - 1 + for _, region in ipairs(regions) do + if region.marker_ours > cursor_line then + vim.api.nvim_win_set_cursor(0, { region.marker_ours + 1, 0 }) + return + end + end + vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) +end + +---@param bufnr integer +function M.goto_prev(bufnr) + local regions = parse_buffer(bufnr) + if #regions == 0 then + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] - 1 + for i = #regions, 1, -1 do + if regions[i].marker_ours < cursor_line then + vim.api.nvim_win_set_cursor(0, { regions[i].marker_ours + 1, 0 }) + return + end + end + vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +local function setup_keymaps(bufnr, config) + local km = config.keymaps + + 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 + +---@param bufnr integer +function M.detach(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + attached_buffers[bufnr] = nil + + if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) + pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = nil + end +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.attach(bufnr, config) + if attached_buffers[bufnr] then + return + end + + local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr }) + if buftype ~= '' then + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_marker = false + for _, line in ipairs(lines) do + if line:match('^<<<<<<<') then + has_marker = true + break + end + end + if not has_marker then + return + end + + attached_buffers[bufnr] = true + + local regions = M.parse(lines) + apply_highlights(bufnr, regions, config) + setup_keymaps(bufnr, config) + + if config.disable_diagnostics then + pcall(vim.diagnostic.enable, false, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = true + end + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + buffer = bufnr, + callback = function() + if not attached_buffers[bufnr] then + return true + end + refresh(bufnr, config) + end, + }) + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + attached_buffers[bufnr] = nil + diagnostics_suppressed[bufnr] = nil + end, + }) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua index 5be95bc..c234c32 100644 --- a/lua/diffs/debug.lua +++ b/lua/diffs/debug.lua @@ -18,14 +18,16 @@ function M.dump() end_col = details.end_col, hl_group = details.hl_group, priority = details.priority, + hl_eol = details.hl_eol, line_hl_group = details.line_hl_group, number_hl_group = details.number_hl_group, virt_text = details.virt_text, } - if not by_line[row] then - by_line[row] = { text = lines[row + 1] or '', marks = {} } + local key = tostring(row) + if not by_line[key] then + by_line[key] = { text = lines[row + 1] or '', marks = {} } end - table.insert(by_line[row].marks, entry) + table.insert(by_line[key].marks, entry) end local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true }) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 5f10f9a..edf0275 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -137,7 +137,12 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) local old_text = table.concat(old_bytes, '\n') .. '\n' local new_text = table.concat(new_bytes, '\n') .. '\n' - local char_hunks = byte_diff(old_text, new_text, diff_opts) + local char_opts = diff_opts + if diff_opts and diff_opts.linematch then + char_opts = { algorithm = diff_opts.algorithm } + end + + local char_hunks = byte_diff(old_text, new_text, char_opts) for _, ch in ipairs(char_hunks) do if ch.old_count > 0 then diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 311a6f3..306b0e5 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,15 +3,53 @@ 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 ---@param col_offset integer ---@param text string ---@param lang string +---@param context_lines? string[] ---@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) - local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang) +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines) + local parse_text = text + if context_lines and #context_lines > 0 then + parse_text = text .. '\n' .. table.concat(context_lines, '\n') + end + + local ok, parser_obj = pcall(vim.treesitter.get_string_parser, parse_text, lang) if not ok or not parser_obj then return 0 end @@ -29,24 +67,26 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local extmark_count = 0 local header_line = hunk.start_line - 1 - for id, node, metadata in query:iter_captures(trees[1]:root(), text) do - local capture_name = '@' .. query.captures[id] .. '.' .. lang - local sr, sc, er, ec = node:range() + for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do + local sr, sc, _, ec = node:range() + if sr == 0 then + local capture_name = '@' .. query.captures[id] .. '.' .. lang - local buf_sr = header_line + sr - local buf_er = header_line + er - local buf_sc = col_offset + sc - local buf_ec = col_offset + ec + local buf_sr = header_line + local buf_er = header_line + local buf_sc = col_offset + sc + local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { - end_row = buf_er, - end_col = buf_ec, - hl_group = capture_name, - priority = priority, - }) - extmark_count = extmark_count + 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { + end_row = buf_er, + end_col = buf_ec, + hl_group = capture_name, + priority = priority, + }) + extmark_count = extmark_count + 1 + end end return extmark_count @@ -58,16 +98,21 @@ end ---@param bufnr integer ---@param ns integer ----@param hunk diffs.Hunk ---@param code_lines string[] ----@param col_offset integer? +---@param lang string +---@param line_map table +---@param col_offset integer +---@param covered_lines? table ---@return integer -local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) - local lang = hunk.lang - if not lang then - return 0 - end - +local function highlight_treesitter( + bufnr, + ns, + code_lines, + lang, + line_map, + col_offset, + covered_lines +) local code = table.concat(code_lines, '\n') if code == '' then return 0 @@ -79,54 +124,50 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) return 0 end - local trees = parser_obj:parse() + local trees = parser_obj:parse(true) if not trees or #trees == 0 then dbg('parse returned no trees for lang: %s', lang) return 0 end - local query = vim.treesitter.query.get(lang, 'highlights') - if not query then - dbg('no highlights query for lang: %s', lang) - return 0 - end - - 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 = 'Normal', - priority = 199, - }) - local header_extmarks = - highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang) - if header_extmarks > 0 then - dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) - end - end - - col_offset = col_offset or 1 - local extmark_count = 0 - for id, node, metadata in query:iter_captures(trees[1]:root(), code) do - local capture_name = '@' .. query.captures[id] .. '.' .. lang - local sr, sc, er, ec = node:range() + parser_obj:for_each_tree(function(tree, ltree) + local tree_lang = ltree:lang() + local query = vim.treesitter.query.get(tree_lang, 'highlights') + if not query then + return + end - local buf_sr = hunk.start_line + sr - local buf_er = hunk.start_line + er - local buf_sc = sc + col_offset - local buf_ec = ec + col_offset + for id, node, metadata in query:iter_captures(tree:root(), code) do + local capture = query.captures[id] + if capture ~= 'spell' and capture ~= 'nospell' then + local capture_name = '@' .. capture .. '.' .. tree_lang + local sr, sc, er, ec = node:range() - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + local buf_sr = line_map[sr] + if buf_sr then + local buf_er = line_map[er] or buf_sr - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { - end_row = buf_er, - end_col = buf_ec, - hl_group = capture_name, - priority = priority, - }) - extmark_count = extmark_count + 1 - end + local buf_sc = sc + col_offset + local buf_ec = ec + col_offset + + local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) + or PRIORITY_SYNTAX + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { + end_row = buf_er, + end_col = buf_ec, + hl_group = capture_name, + priority = priority, + }) + extmark_count = extmark_count + 1 + if covered_lines then + covered_lines[buf_sr] = true + end + end + end + end + end) return extmark_count end @@ -176,8 +217,10 @@ end ---@param ns integer ---@param hunk diffs.Hunk ---@param code_lines string[] +---@param covered_lines? table +---@param leading_offset? integer ---@return integer -local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) local ft = hunk.ft if not ft then return 0 @@ -187,6 +230,8 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) return 0 end + leading_offset = leading_offset or 0 + local scratch = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch }) @@ -213,15 +258,22 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) vim.api.nvim_buf_delete(scratch, { force = true }) + local hunk_line_count = #hunk.lines local extmark_count = 0 for _, span in ipairs(spans) do - local buf_line = hunk.start_line + span.line - 1 - 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 = 200, - }) - extmark_count = extmark_count + 1 + 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, + hl_group = span.hl_name, + priority = PRIORITY_SYNTAX, + }) + extmark_count = extmark_count + 1 + if covered_lines then + covered_lines[buf_line] = true + end + end end return extmark_count @@ -248,21 +300,101 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) use_vim = false end - local apply_syntax = use_ts or use_vim + ---@type table + local covered_lines = {} - ---@type string[] - local code_lines = {} - if apply_syntax then - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(2)) + 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 - extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines) + ---@type string[] + local new_code = {} + ---@type table + local new_map = {} + ---@type string[] + local old_code = {} + ---@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) + local buf_line = hunk.start_line + i - 1 + + if prefix == '+' then + new_map[#new_code] = buf_line + table.insert(new_code, stripped) + elseif prefix == '-' then + old_map[#old_code] = buf_line + table.insert(old_code, stripped) + else + new_map[#new_code] = buf_line + table.insert(new_code, stripped) + table.insert(old_code, stripped) + 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 = extmark_count + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines) + + 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, + }) + local header_extmarks = highlight_text( + bufnr, + ns, + hunk, + hunk.header_context_col, + hunk.header_context, + hunk.lang, + new_code + ) + if header_extmarks > 0 then + dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) + end + extmark_count = extmark_count + header_extmarks + end elseif use_vim then - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines) + ---@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) end if @@ -271,18 +403,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) and #hunk.header_lines > 0 and opts.highlights.treesitter.enabled then + ---@type table + local header_map = {} + for i = 0, #hunk.header_lines - 1 do + header_map[i] = hunk.header_start_line - 1 + i + end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, { - filename = hunk.filename, - start_line = hunk.header_start_line - 1, - lang = 'diff', - lines = hunk.header_lines, - header_lines = {}, - }, hunk.header_lines, 0) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) end - local syntax_applied = extmark_count > 0 - ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra @@ -334,22 +463,27 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end - if line_len > 1 and syntax_applied then + if line_len > 1 and covered_lines[buf_line] then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { end_col = line_len, - hl_group = 'Normal', - priority = 198, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, }) end if opts.highlights.background and is_diff_line then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = line_len, + end_row = buf_line + 1, hl_group = line_hl, hl_eol = true, - number_hl_group = opts.highlights.gutter and number_hl or nil, - priority = 199, + priority = PRIORITY_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, + }) + end end if char_spans_by_line[i] then @@ -367,7 +501,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 = 201, + priority = PRIORITY_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 ca8aa1a..cdc29d1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -11,9 +11,16 @@ ---@field algorithm string ---@field max_lines integer +---@class diffs.ContextConfig +---@field enabled boolean +---@field lines integer + ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean +---@field blend_alpha? number +---@field overrides? table +---@field context diffs.ContextConfig ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig @@ -22,12 +29,27 @@ ---@field horizontal string|false ---@field vertical string|false +---@class diffs.ConflictKeymaps +---@field ours string|false +---@field theirs string|false +---@field both string|false +---@field none string|false +---@field next string|false +---@field prev string|false + +---@class diffs.ConflictConfig +---@field enabled boolean +---@field disable_diagnostics boolean +---@field show_virtual_text boolean +---@field keymaps diffs.ConflictKeymaps + ---@class diffs.Config ---@field debug boolean ---@field debounce_ms integer ---@field hide_prefix boolean ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig +---@field conflict diffs.ConflictConfig ---@class diffs ---@field attach fun(bufnr?: integer) @@ -80,6 +102,10 @@ local default_config = { highlights = { background = true, gutter = true, + context = { + enabled = true, + lines = 25, + }, treesitter = { enabled = true, max_lines = 500, @@ -98,6 +124,19 @@ local default_config = { horizontal = 'du', vertical = 'dU', }, + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, } ---@type diffs.Config @@ -183,13 +222,19 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_fg, bg, 0.7) - local blended_del_text = blend_color(del_fg, bg, 0.7) + local alpha = config.highlights.blend_alpha or 0.6 + 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, '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 = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) + vim.api.nvim_set_hl( + 0, + 'DiffsDeleteNr', + { default = true, fg = blended_del_text, bg = blended_del } + ) vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text }) @@ -205,10 +250,51 @@ local function compute_highlight_groups() local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') - vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg }) + vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg }) + vim.api.nvim_set_hl( + 0, + 'DiffsDiffDelete', + { default = true, fg = diff_delete.fg, bg = diff_delete.bg } + ) + vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = true, bg = diff_change.bg }) + vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg }) + + local change_bg = diff_change.bg or 0x3a3a4a + local text_bg = diff_text.bg or 0x4a4a5a + local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0 + + local blended_ours = blend_color(add_bg, bg, 0.4) + local blended_theirs = blend_color(change_bg, bg, 0.4) + local blended_base = blend_color(text_bg, bg, 0.3) + local blended_ours_nr = blend_color(add_fg, bg, alpha) + local blended_theirs_nr = blend_color(change_fg, bg, alpha) + local blended_base_nr = blend_color(change_fg, bg, 0.4) + + vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours }) + 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, + 'DiffsConflictOursNr', + { default = true, fg = blended_ours_nr, bg = blended_ours } + ) + vim.api.nvim_set_hl( + 0, + 'DiffsConflictTheirsNr', + { default = true, fg = blended_theirs_nr, bg = blended_theirs } + ) + vim.api.nvim_set_hl( + 0, + 'DiffsConflictBaseNr', + { default = true, fg = blended_base_nr, bg = blended_base } + ) + + if config.highlights.overrides then + for group, hl in pairs(config.highlights.overrides) do + vim.api.nvim_set_hl(0, group, hl) + end + end end local function init() @@ -230,11 +316,21 @@ local function init() 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 }, }) + 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 }, + }) + end + if opts.highlights.treesitter then vim.validate({ ['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true }, @@ -287,9 +383,41 @@ local function init() }) 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.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', + }, + }) + end + 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 + and opts.highlights.context.lines + and opts.highlights.context.lines < 0 + then + error('diffs: highlights.context.lines must be >= 0') + end if opts.highlights and opts.highlights.treesitter @@ -314,6 +442,13 @@ local function init() then error('diffs: highlights.intra.max_lines must be >= 1') end + if + opts.highlights + and opts.highlights.blend_alpha + and (opts.highlights.blend_alpha < 0 or opts.highlights.blend_alpha > 1) + then + error('diffs: highlights.blend_alpha must be >= 0 and <= 1') + end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) @@ -436,4 +571,10 @@ function M.get_fugitive_config() return config.fugitive end +---@return diffs.ConflictConfig +function M.get_conflict_config() + init() + return config.conflict +end + return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 52b2864..43eb1f6 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -8,6 +8,11 @@ ---@field lines string[] ---@field header_start_line integer? ---@field header_lines string[]? +---@field file_old_start integer? +---@field file_old_count integer? +---@field file_new_start integer? +---@field file_new_count integer? +---@field repo_root string? local M = {} @@ -132,6 +137,14 @@ function M.parse_buffer(bufnr) local header_start = nil ---@type string[] local header_lines = {} + ---@type integer? + local file_old_start = nil + ---@type integer? + local file_old_count = nil + ---@type integer? + local file_new_start = nil + ---@type integer? + local file_new_count = nil local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -143,6 +156,11 @@ function M.parse_buffer(bufnr) header_context = hunk_header_context, header_context_col = hunk_header_context_col, lines = hunk_lines, + file_old_start = file_old_start, + file_old_count = file_old_count, + file_new_start = file_new_start, + file_new_count = file_new_count, + repo_root = repo_root, } if hunk_count == 1 and header_start and #header_lines > 0 then hunk.header_start_line = header_start @@ -154,6 +172,10 @@ function M.parse_buffer(bufnr) hunk_header_context = nil hunk_header_context_col = nil hunk_lines = {} + file_old_start = nil + file_old_count = nil + file_new_start = nil + file_new_count = nil end for i, line in ipairs(lines) do @@ -174,6 +196,13 @@ function M.parse_buffer(bufnr) 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 + end local prefix, context = line:match('^(@@.-@@%s*)(.*)') if context and context ~= '' then hunk_header_context = context diff --git a/plugin/diffs.lua b/plugin/diffs.lua index c51d417..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -30,6 +30,15 @@ vim.api.nvim_create_autocmd('BufReadCmd', { end, }) +vim.api.nvim_create_autocmd('BufReadPost', { + callback = function(args) + local conflict_config = require('diffs').get_conflict_config() + if conflict_config.enabled then + require('diffs.conflict').attach(args.buf, conflict_config) + end + end, +}) + vim.api.nvim_create_autocmd('OptionSet', { pattern = 'diff', callback = function() @@ -40,3 +49,36 @@ vim.api.nvim_create_autocmd('OptionSet', { end end, }) + +local cmds = require('diffs.commands') +vim.keymap.set('n', '(diffs-gdiff)', function() + cmds.gdiff(nil, false) +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' }) diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua new file mode 100644 index 0000000..75eac23 --- /dev/null +++ b/spec/conflict_spec.lua @@ -0,0 +1,688 @@ +local conflict = require('diffs.conflict') +local helpers = require('spec.helpers') + +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_file_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true }) +end + +describe('conflict', function() + describe('parse', function() + it('parses a single conflict', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(0, regions[1].marker_ours) + assert.are.equal(1, regions[1].ours_start) + assert.are.equal(2, regions[1].ours_end) + assert.are.equal(2, regions[1].marker_sep) + assert.are.equal(3, regions[1].theirs_start) + assert.are.equal(4, regions[1].theirs_end) + assert.are.equal(4, regions[1].marker_theirs) + end) + + it('parses multiple conflicts', function() + local lines = { + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'normal line', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + } + local regions = conflict.parse(lines) + assert.are.equal(2, #regions) + assert.are.equal(0, regions[1].marker_ours) + assert.are.equal(6, regions[2].marker_ours) + end) + + it('parses diff3 format', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(2, regions[1].marker_base) + assert.are.equal(3, regions[1].base_start) + assert.are.equal(4, regions[1].base_end) + end) + + it('handles empty ours section', function() + local lines = { + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(1, regions[1].ours_start) + assert.are.equal(1, regions[1].ours_end) + end) + + it('handles empty theirs section', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(3, regions[1].theirs_start) + assert.are.equal(3, regions[1].theirs_end) + end) + + it('returns empty for no markers', function() + local lines = { 'local x = 1', 'local y = 2' } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('discards malformed markers (no separator)', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('discards malformed markers (no end)', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('handles trailing text on marker lines', function() + local lines = { + '<<<<<<< HEAD (some text)', + 'local x = 1', + '======= extra', + 'local x = 2', + '>>>>>>> feature-branch/some-thing', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + end) + + it('handles empty base in diff3', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(3, regions[1].base_start) + assert.are.equal(3, regions[1].base_end) + end) + end) + + describe('highlighting', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('applies extmarks for conflict regions', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + + local has_ours = false + local has_theirs = false + local has_marker = false + for _, mark in ipairs(extmarks) do + local hl = mark[4] and mark[4].hl_group + if hl == 'DiffsConflictOurs' then + has_ours = true + end + if hl == 'DiffsConflictTheirs' then + has_theirs = true + end + if hl == 'DiffsConflictMarker' then + has_marker = true + end + end + assert.is_true(has_ours) + assert.is_true(has_theirs) + assert.is_true(has_marker) + + helpers.delete_buffer(bufnr) + end) + + it('applies virtual text when enabled', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_virtual_text = true })) + + 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(2, virt_text_count) + + 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', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local has_ours_nr = false + local has_theirs_nr = false + for _, mark in ipairs(extmarks) do + local nr = mark[4] and mark[4].number_hl_group + if nr == 'DiffsConflictOursNr' then + has_ours_nr = true + end + if nr == 'DiffsConflictTheirsNr' then + has_theirs_nr = true + end + end + assert.is_true(has_ours_nr) + assert.is_true(has_theirs_nr) + + helpers.delete_buffer(bufnr) + end) + + it('highlights base region in diff3', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local has_base = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then + has_base = true + break + end + end + assert.is_true(has_base) + + helpers.delete_buffer(bufnr) + end) + + it('clears extmarks on detach', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + assert.is_true(#get_extmarks(bufnr) > 0) + + conflict.detach(bufnr) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('resolution', function() + local function make_conflict_buffer() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + return bufnr + end + + it('resolve_ours keeps ours content', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 1', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_theirs keeps theirs content', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_theirs(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_both keeps ours then theirs', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_both(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(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]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_none removes entire block', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_none(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('does nothing when cursor is outside conflict', function() + local bufnr = create_file_buffer({ + 'normal line', + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(6, #lines) + + helpers.delete_buffer(bufnr) + end) + + it('resolves one conflict among multiple', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('a', lines[1]) + assert.are.equal('middle', lines[2]) + assert.are.equal('<<<<<<< HEAD', lines[3]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_ours with empty ours section', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('handles diff3 resolution (ignores base)', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_theirs(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('navigation', function() + it('goto_next jumps to next conflict', function() + local bufnr = create_file_buffer({ + 'normal', + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.goto_next(bufnr) + assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.goto_next(bufnr) + assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_next wraps to first conflict', 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 }) + + conflict.goto_next(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_prev jumps to previous conflict', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + 'end', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 12, 0 }) + + conflict.goto_prev(bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.goto_prev(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_prev wraps to last conflict', 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 }) + + conflict.goto_prev(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + 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) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.goto_next(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('lifecycle', function() + it('attach is idempotent', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + local cfg = default_config() + conflict.attach(bufnr, cfg) + local count1 = #get_extmarks(bufnr) + conflict.attach(bufnr, cfg) + local count2 = #get_extmarks(bufnr) + assert.are.equal(count1, count2) + conflict.detach(bufnr) + helpers.delete_buffer(bufnr) + end) + + it('skips non-file buffers', function() + local bufnr = helpers.create_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr }) + + conflict.attach(bufnr, default_config()) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + + it('skips buffers without conflict markers', function() + local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' }) + + conflict.attach(bufnr, default_config()) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + + it('re-highlights when markers return after resolution', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + local cfg = default_config() + conflict.attach(bufnr, cfg) + + assert.is_true(#get_extmarks(bufnr) > 0) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + conflict.resolve_ours(bufnr, cfg) + assert.are.equal(0, #get_extmarks(bufnr)) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + + assert.is_true(#get_extmarks(bufnr) > 0) + + conflict.detach(bufnr) + helpers.delete_buffer(bufnr) + end) + + it('detaches after last conflict resolved', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + conflict.attach(bufnr, default_config()) + + assert.is_true(#get_extmarks(bufnr) > 0) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + conflict.resolve_ours(bufnr, default_config()) + + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/helpers.lua b/spec/helpers.lua index 34128ac..7774cf4 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -12,6 +12,7 @@ local function ensure_parser(lang) end ensure_parser('lua') +ensure_parser('vim') local M = {} diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index bb125fd..0b9051e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -7,8 +7,10 @@ describe('highlight', function() before_each(function() ns = vim.api.nvim_create_namespace('diffs_test') + 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 }) vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg }) end) @@ -35,6 +37,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500, @@ -82,7 +85,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('applies Normal extmarks to clear diff colors', function() + it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -99,14 +102,46 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local has_normal = false + local has_clear = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'Normal' then - has_normal = true + if mark[4] and mark[4].hl_group == 'DiffsClear' then + has_clear = true break end end - assert.is_true(has_normal) + assert.is_true(has_clear) + delete_buffer(bufnr) + end) + + it('produces treesitter captures on all lines with split parsing', function() + local bufnr = create_buffer({ + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' return x' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local lines_with_ts = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + lines_with_ts[mark[2]] = true + end + end + assert.is_true(lines_with_ts[1] ~= nil) + assert.is_true(lines_with_ts[2] ~= nil) + assert.is_true(lines_with_ts[3] ~= nil) + assert.is_true(lines_with_ts[4] ~= nil) delete_buffer(bufnr) end) @@ -185,6 +220,40 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('highlights function keyword in header context', function() + local bufnr = create_buffer({ + '@@ -5,3 +5,4 @@ function M.setup()', + ' local x = 1', + '+local y = 2', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function M.setup()', + header_context_col = 18, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_keyword_function = false + for _, mark in ipairs(extmarks) do + if mark[2] == 0 and mark[4] and mark[4].hl_group then + local hl = mark[4].hl_group + if hl == '@keyword.function.lua' or hl == '@keyword.lua' then + has_keyword_function = true + break + end + end + end + assert.is_true(has_keyword_function) + delete_buffer(bufnr) + end) + it('does not highlight header when no header_context', function() local bufnr = create_buffer({ '@@ -10,3 +10,4 @@', @@ -576,7 +645,7 @@ describe('highlight', function() 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 ~= 'Normal' then + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then has_syntax_hl = true break end @@ -610,7 +679,7 @@ describe('highlight', function() 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 ~= 'Normal' then + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then has_syntax_hl = true break end @@ -682,7 +751,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('applies Normal blanking for vim fallback hunks', function() + it('applies DiffsClear blanking for vim fallback hunks', function() local orig_synID = vim.fn.synID local orig_synIDtrans = vim.fn.synIDtrans local orig_synIDattr = vim.fn.synIDattr @@ -722,14 +791,14 @@ describe('highlight', function() vim.fn.synIDattr = orig_synIDattr local extmarks = get_extmarks(bufnr) - local has_normal = false + local has_clear = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'Normal' then - has_normal = true + if mark[4] and mark[4].hl_group == 'DiffsClear' then + has_clear = true break end end - assert.is_true(has_normal) + assert.is_true(has_clear) delete_buffer(bufnr) end) @@ -765,7 +834,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('line bg priority > Normal priority', function() + it('hl_eol background extmarks are multiline so hl_eol takes effect', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -787,20 +856,86 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) - local normal_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 + assert.is_true(d.end_row > mark[2]) + 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 @@', + ' local a = 0', + '-local x = 1', + '+local y = 2', + ' local b = 3', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local a = 0', '-local x = 1', '+local y = 2', ' local b = 3' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, gutter = true } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.number_hl_group then + local start_row = mark[2] + local end_row = d.end_row or start_row + assert.are.equal(start_row, end_row) + end + end + 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 == 'Normal' then - normal_priority = d.priority + 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(normal_priority) + assert.is_not_nil(clear_priority) assert.is_not_nil(line_bg_priority) - assert.is_true(line_bg_priority > normal_priority) + assert.is_true(line_bg_priority > clear_priority) delete_buffer(bufnr) end) @@ -960,7 +1095,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('enforces priority order: Normal < line bg < syntax < char bg', function() + it('enforces priority order: DiffsClear < syntax < line bg < char bg', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) @@ -990,12 +1125,12 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) - local priorities = { normal = {}, line_bg = {}, syntax = {}, char_bg = {} } + local priorities = { clear = {}, line_bg = {}, syntax = {}, char_bg = {} } for _, mark in ipairs(extmarks) do local d = mark[4] if d then - if d.hl_group == 'Normal' then - table.insert(priorities.normal, d.priority) + if d.hl_group == 'DiffsClear' then + table.insert(priorities.clear, d.priority) elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then @@ -1006,19 +1141,215 @@ describe('highlight', function() end end - assert.is_true(#priorities.normal > 0) + assert.is_true(#priorities.clear > 0) assert.is_true(#priorities.line_bg > 0) assert.is_true(#priorities.syntax > 0) assert.is_true(#priorities.char_bg > 0) - local max_normal = math.max(unpack(priorities.normal)) + local max_clear = math.max(unpack(priorities.clear)) local min_line_bg = math.min(unpack(priorities.line_bg)) local min_syntax = math.min(unpack(priorities.syntax)) local min_char_bg = math.min(unpack(priorities.char_bg)) - assert.is_true(max_normal < min_line_bg) - assert.is_true(min_line_bg < min_syntax) - assert.is_true(min_syntax < min_char_bg) + assert.is_true(max_clear < min_syntax) + assert.is_true(min_syntax < min_line_bg) + assert.is_true(min_line_bg < min_char_bg) + 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 @@', + ' 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_lua = false + local has_vim = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group then + if mark[4].hl_group:match('^@.*%.lua$') then + has_lua = true + end + if mark[4].hl_group:match('^@.*%.vim$') then + has_vim = true + end + end + end + assert.is_true(has_lua) + assert.is_true(has_vim) + delete_buffer(bufnr) + end) + + it('filters @spell and @nospell captures from 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) + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group then + assert.is_falsy(mark[4].hl_group:match('@spell')) + assert.is_falsy(mark[4].hl_group:match('@nospell')) + end + end delete_buffer(bufnr) end) end) @@ -1052,6 +1383,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, @@ -1208,13 +1540,14 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, } end - it('uses priority 200 for code languages', function() + it('uses priority 199 for code languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -1231,16 +1564,16 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local has_priority_200 = false + 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 == 200 then - has_priority_200 = true + if mark[4].priority == 199 then + has_priority_199 = true break end end end - assert.is_true(has_priority_200) + assert.is_true(has_priority_199) delete_buffer(bufnr) end) @@ -1278,7 +1611,7 @@ describe('highlight', function() end assert.is_true(#diff_extmark_priorities > 0) for _, priority in ipairs(diff_extmark_priorities) do - assert.is_true(priority < 200) + assert.is_true(priority < 199) end delete_buffer(bufnr) end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 89d0ac8..11ac3be 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -421,5 +421,84 @@ describe('parser', function() 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', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(3, hunks[1].file_old_count) + assert.are.equal(1, hunks[1].file_new_start) + assert.are.equal(4, hunks[1].file_new_count) + 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', + '@@ -1 +1 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(1, hunks[1].file_old_count) + assert.are.equal(1, hunks[1].file_new_start) + assert.are.equal(1, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + + it('stores repo_root on hunk when available', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp/test-repo') + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + 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) diff --git a/spec/ux_spec.lua b/spec/ux_spec.lua new file mode 100644 index 0000000..9cf0110 --- /dev/null +++ b/spec/ux_spec.lua @@ -0,0 +1,135 @@ +local commands = require('diffs.commands') +local helpers = require('spec.helpers') + +local counter = 0 + +local function create_diffs_buffer(name) + counter = counter + 1 + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr }) + vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) + vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua')) + return bufnr +end + +describe('ux', function() + describe('diagnostics', function() + it('disables diagnostics on diff buffers', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + assert.is_false(vim.diagnostic.is_enabled({ bufnr = bufnr })) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('does not affect other buffers', function() + local diff_buf = create_diffs_buffer() + local normal_buf = helpers.create_buffer({ 'hello' }) + + commands.setup_diff_buf(diff_buf) + + assert.is_true(vim.diagnostic.is_enabled({ bufnr = normal_buf })) + vim.api.nvim_buf_delete(diff_buf, { force = true }) + helpers.delete_buffer(normal_buf) + end) + end) + + describe('q keymap', function() + it('sets q keymap on diff buffer', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n') + local has_q = false + for _, km in ipairs(keymaps) do + if km.lhs == 'q' then + has_q = true + break + end + end + assert.is_true(has_q) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('q closes the window', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, bufnr) + + local win_count_before = #vim.api.nvim_tabpage_list_wins(0) + + vim.api.nvim_buf_call(bufnr, function() + vim.cmd('normal q') + end) + + local win_count_after = #vim.api.nvim_tabpage_list_wins(0) + assert.equals(win_count_before - 1, win_count_after) + end) + end) + + describe('window reuse', function() + it('returns nil when no diffs window exists', function() + local win = commands.find_diffs_window() + assert.is_nil(win) + end) + + it('finds existing diffs:// window', function() + local bufnr = create_diffs_buffer() + vim.cmd('split') + local expected_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(expected_win, bufnr) + + local found = commands.find_diffs_window() + assert.equals(expected_win, found) + + vim.api.nvim_win_close(expected_win, true) + end) + + it('ignores non-diffs buffers', function() + local normal_buf = helpers.create_buffer({ 'hello' }) + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, normal_buf) + + local found = commands.find_diffs_window() + assert.is_nil(found) + + vim.api.nvim_win_close(win, true) + helpers.delete_buffer(normal_buf) + end) + + it('returns first diffs window when multiple exist', function() + local buf1 = create_diffs_buffer() + local buf2 = create_diffs_buffer() + + vim.cmd('split') + local win1 = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win1, buf1) + + vim.cmd('split') + local win2 = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win2, buf2) + + local found = commands.find_diffs_window() + assert.is_not_nil(found) + assert.is_true(found == win1 or found == win2) + + vim.api.nvim_win_close(win1, true) + vim.api.nvim_win_close(win2, true) + end) + end) +end)