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)