Compare commits

..

No commits in common. "doc/merge-conflicts" and "fix/ux-tweaks" have entirely different histories.

6 changed files with 3 additions and 1427 deletions

View file

@ -18,7 +18,6 @@ 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
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
@ -64,8 +63,7 @@ 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) -
`diffs.nvim` now includes built-in conflict resolution; disable one or the
other to avoid overlap
conflict marker highlighting may overlap with `diffs.nvim`
# Acknowledgements

View file

@ -20,7 +20,6 @@ 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*
@ -81,19 +80,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
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*
@ -122,10 +108,6 @@ 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)
@ -280,41 +262,6 @@ Example configuration: >lua
vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)')
<
*<Plug>(diffs-conflict-ours)*
<Plug>(diffs-conflict-ours)
Accept current (ours) change. Replaces the
conflict block with ours content.
*<Plug>(diffs-conflict-theirs)*
<Plug>(diffs-conflict-theirs)
Accept incoming (theirs) change. Replaces the
conflict block with theirs content.
*<Plug>(diffs-conflict-both)*
<Plug>(diffs-conflict-both)
Accept both changes (ours then theirs).
*<Plug>(diffs-conflict-none)*
<Plug>(diffs-conflict-none)
Reject both changes (delete entire block).
*<Plug>(diffs-conflict-next)*
<Plug>(diffs-conflict-next)
Jump to next conflict marker. Wraps around.
*<Plug>(diffs-conflict-prev)*
<Plug>(diffs-conflict-prev)
Jump to previous conflict marker. Wraps around.
Example configuration: >lua
vim.keymap.set('n', 'co', '<Plug>(diffs-conflict-ours)')
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
<
Diff buffer mappings: ~
*diffs-q*
q Close the diff window. Available in all `diffs://`
@ -370,94 +317,6 @@ 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*
@ -555,9 +414,8 @@ conflict:
modifying line highlights may produce unexpected results.
- git-conflict.nvim (akinsho/git-conflict.nvim)
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.
Provides conflict marker highlighting that may overlap with
diffs.nvim's highlighting in conflict scenarios.
If you experience visual conflicts, try disabling the conflicting plugin's
diff-related features.
@ -604,37 +462,6 @@ Fugitive unified diff highlights: ~
within `-` lines. Derived by blending `diffRemoved`
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.

View file

@ -1,438 +0,0 @@
---@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<integer, true>
local attached_buffers = {}
---@type table<integer, boolean>
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, '<Plug>(diffs-conflict-ours)' },
{ km.theirs, '<Plug>(diffs-conflict-theirs)' },
{ km.both, '<Plug>(diffs-conflict-both)' },
{ km.none, '<Plug>(diffs-conflict-none)' },
{ km.next, '<Plug>(diffs-conflict-next)' },
{ km.prev, '<Plug>(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

View file

@ -29,27 +29,12 @@
---@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)
@ -124,19 +109,6 @@ 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
@ -259,37 +231,6 @@ local function compute_highlight_groups()
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)
@ -383,30 +324,6 @@ 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
@ -571,10 +488,4 @@ function M.get_fugitive_config()
return config.fugitive
end
---@return diffs.ConflictConfig
function M.get_conflict_config()
init()
return config.conflict
end
return M

View file

@ -30,15 +30,6 @@ 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()
@ -57,28 +48,3 @@ end, { desc = 'Unified diff (horizontal)' })
vim.keymap.set('n', '<Plug>(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', '<Plug>(diffs-conflict-ours)', function()
conflict_action(require('diffs.conflict').resolve_ours)
end, { desc = 'Accept current (ours) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-theirs)', function()
conflict_action(require('diffs.conflict').resolve_theirs)
end, { desc = 'Accept incoming (theirs) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-both)', function()
conflict_action(require('diffs.conflict').resolve_both)
end, { desc = 'Accept both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-none)', function()
conflict_action(require('diffs.conflict').resolve_none)
end, { desc = 'Reject both changes' })
vim.keymap.set('n', '<Plug>(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', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })

View file

@ -1,688 +0,0 @@
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)