feat(conflict): detect and resolve inline merge conflict markers

Problem: when git hits a merge conflict, users stare at raw <<<<<<<
markers with broken treesitter and noisy LSP diagnostics. Existing
solutions (git-conflict.nvim) use their own highlighting rather than
integrating with diffs.nvim's color blending pipeline.

Solution: add conflict.lua module that detects <<<<<<</=======/>>>>>>>
markers (with diff3 ||||||| support), highlights ours/theirs/base
regions with blended DiffsConflict* highlight groups, provides
resolution keymaps (doo/dot/dob/don) and navigation (]x/[x),
suppresses diagnostics while markers are present, and auto-detaches
when all conflicts are resolved. Fires DiffsConflictResolved user
event on last resolution.
This commit is contained in:
Barrett Ruth 2026-02-07 17:38:34 -05:00
parent e06d22936c
commit 731222d027
4 changed files with 1210 additions and 0 deletions

457
lua/diffs/conflict.lua Normal file
View file

@ -0,0 +1,457 @@
---@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.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'
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
M.detach(bufnr)
vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' })
return
end
apply_highlights(bufnr, regions, config)
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
if km.ours then
vim.keymap.set('n', km.ours, function()
M.resolve_ours(bufnr, config)
end, { buffer = bufnr })
end
if km.theirs then
vim.keymap.set('n', km.theirs, function()
M.resolve_theirs(bufnr, config)
end, { buffer = bufnr })
end
if km.both then
vim.keymap.set('n', km.both, function()
M.resolve_both(bufnr, config)
end, { buffer = bufnr })
end
if km.none then
vim.keymap.set('n', km.none, function()
M.resolve_none(bufnr, config)
end, { buffer = bufnr })
end
if km.next then
vim.keymap.set('n', km.next, function()
M.goto_next(bufnr)
end, { buffer = bufnr })
end
if km.prev then
vim.keymap.set('n', km.prev, function()
M.goto_prev(bufnr)
end, { buffer = bufnr })
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.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,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)
@ -109,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
@ -231,6 +259,37 @@ 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)
@ -324,6 +383,30 @@ 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
@ -488,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