diff --git a/README.md b/README.md index a3a7ae4..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,18 @@ syntax highlighting. ## Features -- Treesitter syntax highlighting in fugitive diffs and commit views -- Character-level intra-line diff highlighting (with optional - [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for - word-level accuracy) -- `:Gdiff` unified diff against any revision -- Background-only diff colors for `&diff` buffers -- Inline merge conflict detection, highlighting, and resolution -- Vim syntax fallback, configurable blend/debounce/priorities +- Treesitter syntax highlighting in `:Git` diffs and commit views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) +- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds +- `:Gdiff` unified diff against any git revision with syntax highlighting +- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs +- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) +- Vim syntax fallback for languages without a treesitter parser +- Hunk header context highlighting (`@@ ... @@ function foo()`) +- Character-level (intra-line) diff highlighting for changed characters +- Inline merge conflict detection, highlighting, and resolution keymaps +- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and + highlight overrides ## Requirements @@ -40,9 +44,10 @@ luarocks install diffs.nvim ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - Context lines within the hunk (` ` prefix) provide syntactic context for the - parser. In rare cases, hunks that start or end mid-expression may produce - imperfect highlights due to treesitter error recovery. + 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 @@ -65,9 +70,7 @@ luarocks install diffs.nvim # Acknowledgements - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) -- [@esmuellert](https://github.com/esmuellert) / - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff - algorithm FFI backend for word-level intra-line accuracy +- [`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) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index d1df476..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -75,12 +75,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, overrides = {}, }, fugitive = { @@ -91,7 +85,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -167,10 +160,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. - {priorities} (table, default: see below) - Extmark priority values. - See |diffs.PrioritiesConfig| for fields. - {overrides} (table, default: {}) Map of highlight group names to highlight definitions (see |nvim_set_hl()|). Applied @@ -192,28 +181,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: direction. Lines are read with early exit — cost scales with this value, not file size. - *diffs.PrioritiesConfig* - Priorities config fields: ~ - {clear} (integer, default: 198) - Priority for `DiffsClear` extmarks that reset - underlying diff foreground colors. Must be - below {syntax}. - - {syntax} (integer, default: 199) - Priority for treesitter and vim syntax extmarks. - Must be below {line_bg} so that colorscheme - backgrounds on syntax groups do not obscure - line-level diff backgrounds. - - {line_bg} (integer, default: 200) - Priority for `DiffsAdd`/`DiffsDelete` line - background extmarks. Must be below {char_bg}. - - {char_bg} (integer, default: 201) - Priority for `DiffsAddText`/`DiffsDeleteText` - character-level background extmarks. Highest - priority so changed characters stand out. - *diffs.TreesitterConfig* Treesitter config fields: ~ {enabled} (boolean, default: true) @@ -348,32 +315,6 @@ Example configuration: >lua vim.keymap.set('n', '[x', '(diffs-conflict-prev)') < - *(diffs-merge-ours)* -(diffs-merge-ours) - Accept ours in a merge diff view. Resolves the - conflict in the working file with ours content. - - *(diffs-merge-theirs)* -(diffs-merge-theirs) - Accept theirs in a merge diff view. - - *(diffs-merge-both)* -(diffs-merge-both) - Accept both (ours then theirs) in a merge diff view. - - *(diffs-merge-none)* -(diffs-merge-none) - Reject both in a merge diff view. - - *(diffs-merge-next)* -(diffs-merge-next) - Jump to next unresolved conflict hunk in merge diff. - - *(diffs-merge-prev)* -(diffs-merge-prev) - Jump to previous unresolved conflict hunk in merge - diff. - Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` @@ -404,7 +345,6 @@ Behavior by file status: ~ A Staged (empty) index file as all-added D Staged HEAD (empty) file as all-removed R Staged HEAD:oldname index:newname content diff - U Unstaged :2: (ours) :3: (theirs) merge diff ? Untracked (empty) working tree file as all-added On section headers, the keymap runs `git diff` (or `git diff --cached` for @@ -449,8 +389,6 @@ Configuration: ~ enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, - priority = 200, keymaps = { ours = 'doo', theirs = 'dot', @@ -477,37 +415,9 @@ Configuration: ~ diagnostics alone. {show_virtual_text} (boolean, default: true) - Show `(current)` and `(incoming)` labels at - the end of `<<<<<<<` and `>>>>>>>` marker - lines. Also controls hunk hints in merge - diff views. - - {format_virtual_text} (function|nil, default: nil) - Custom formatter for virtual text labels. - Receives `(side, keymap)` where `side` is - `"ours"` or `"theirs"` and `keymap` is the - configured keymap string or `false`. Return - a string (label text without parens) or - `nil` to hide the label. Example: >lua - format_virtual_text = function(side, keymap) - if keymap then - return side .. ' [' .. keymap .. ']' - end - return side - end -< - - {show_actions} (boolean, default: false) - Show a codelens-style action line above each - `<<<<<<<` marker listing available resolution - keymaps. Renders as virtual lines using the - `DiffsConflictActions` highlight group. - Only keymaps that are not `false` appear. - - {priority} (integer, default: 200) - Extmark priority for conflict region - backgrounds and markers. Adjust if other - plugins use the same priority range. + 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 @@ -548,31 +458,6 @@ User events: ~ }) < -============================================================================== -MERGE DIFF RESOLUTION *diffs-merge* - -When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status -buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs -theirs (`git show :3:path`) with full treesitter and intra-line highlighting. - -The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]x`/`[x`) -are available on the diff buffer. They resolve conflicts in the working -file by matching diff hunks to conflict markers: - -- `doo` replaces the conflict region with ours content -- `dot` replaces the conflict region with theirs content -- `dob` replaces with both (ours then theirs) -- `don` removes the conflict region entirely -- `]x`/`[x` navigate between unresolved conflict hunks - -Resolved hunks are marked with `(resolved)` virtual text. Hunks that -correspond to auto-merged content (no conflict markers) show an -informational notification and are left unchanged. - -The working file buffer is modified in place; save it when ready. -Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed -automatically after each resolution. - ============================================================================== API *diffs-api* @@ -604,13 +489,12 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - `DiffsClear` extmarks at priority 198 clear underlying diff foreground - - Syntax highlights are applied as extmarks at priority 199 - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200 + - `Normal` extmarks at priority 198 clear underlying diff foreground + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 + - Syntax highlights are applied as extmarks at priority 200 - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled - - All priorities are configurable via |diffs.PrioritiesConfig| 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events Diff mode views: ~ @@ -625,10 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation. Context lines within the hunk -(lines with a ` ` prefix) provide syntactic context for the parser. In rare -cases, hunks that start or end mid-expression may produce imperfect highlights -due to treesitter error recovery. +Treesitter parses each diff hunk in isolation. To provide surrounding code +context, diffs.nvim reads lines from disk before and after each hunk +(see |diffs.ContextConfig|, enabled by default). This resolves most boundary +issues where incomplete constructs (e.g., a function definition at the edge +of a hunk with no body) would confuse the parser. + +Set `highlights.context.enabled = false` to disable context padding. In rare +cases, context padding may not help if the relevant surrounding code is very +far from the hunk boundaries. Syntax Highlighting Flash ~ *diffs-flash* @@ -746,10 +635,6 @@ Conflict highlights: ~ *DiffsConflictBaseNr* DiffsConflictBaseNr Line number for base content lines (diff3). - *DiffsConflictActions* - DiffsConflictActions Dimmed foreground (no bold) for the codelens-style - action line shown when `show_actions` is true. - Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 01f1d3a..dc6cfe2 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -36,24 +36,6 @@ function M.find_hunk_line(diff_lines, hunk_position) return nil end ----@param lines string[] ----@return string[] -function M.filter_combined_diffs(lines) - local result = {} - local skip = false - for _, line in ipairs(lines) do - if line:match('^diff %-%-cc ') then - skip = true - elseif line:match('^diff %-%-git ') then - skip = false - end - if not skip then - table.insert(result, line) - end - end - return result -end - ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -87,33 +69,6 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name) return result end ----@param raw_lines string[] ----@param repo_root string ----@return string[] -local function replace_combined_diffs(raw_lines, repo_root) - local unmerged_files = {} - for _, line in ipairs(raw_lines) do - local cc_file = line:match('^diff %-%-cc (.+)$') - if cc_file then - table.insert(unmerged_files, cc_file) - end - end - - local result = M.filter_combined_diffs(raw_lines) - - for _, filename in ipairs(unmerged_files) do - local filepath = repo_root .. '/' .. filename - local old_lines = git.get_file_content(':2', filepath) or {} - local new_lines = git.get_file_content(':3', filepath) or {} - local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename) - for _, dl in ipairs(diff_lines) do - table.insert(result, dl) - end - end - - return result -end - ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) @@ -183,7 +138,6 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean ----@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } @@ -203,17 +157,7 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.unmerged then - old_lines = git.get_file_content(':2', filepath) - if not old_lines then - old_lines = {} - end - new_lines = git.get_file_content(':3', filepath) - if not new_lines then - new_lines = {} - end - diff_label = 'unmerged' - elseif opts.untracked then + if opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then @@ -292,14 +236,6 @@ function M.gdiff_file(filepath, opts) end M.setup_diff_buf(diff_buf) - - if diff_label == 'unmerged' then - vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) - local conflict_config = require('diffs').get_conflict_config() - require('diffs.merge').setup_keymaps(diff_buf, conflict_config) - end - dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -327,8 +263,6 @@ function M.gdiff_section(repo_root, opts) return end - result = replace_combined_diffs(result, repo_root) - if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return @@ -391,8 +325,6 @@ function M.read_buffer(bufnr) if vim.v.shell_error ~= 0 then diff_lines = {} end - - diff_lines = replace_combined_diffs(diff_lines, repo_root) else local abs_path = repo_root .. '/' .. path @@ -402,10 +334,7 @@ function M.read_buffer(bufnr) local old_lines, new_lines - if label == 'unmerged' then - old_lines = git.get_file_content(':2', abs_path) or {} - new_lines = git.get_file_content(':3', abs_path) or {} - elseif label == 'untracked' then + if label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 5ed8009..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -20,6 +20,8 @@ local attached_buffers = {} ---@type table local diagnostics_suppressed = {} +local PRIORITY_LINE_BG = 200 + ---@param lines string[] ---@return diffs.ConflictRegion[] function M.parse(lines) @@ -90,17 +92,6 @@ local function parse_buffer(bufnr) return M.parse(lines) end ----@param side string ----@param config diffs.ConflictConfig ----@return string? -local function get_virtual_text_label(side, config) - if config.format_virtual_text then - local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs - return config.format_virtual_text(side, keymap) - end - return side == 'ours' and 'current' or 'incoming' -end - ---@param bufnr integer ---@param regions diffs.ConflictRegion[] ---@param config diffs.ConflictConfig @@ -112,41 +103,14 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_ours + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) if config.show_virtual_text then - local ours_label = get_virtual_text_label('ours', config) - if ours_label then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) - end - end - - if config.show_actions then - local parts = {} - local actions = { - { 'Current', config.keymaps.ours }, - { 'Incoming', config.keymaps.theirs }, - { 'Both', config.keymaps.both }, - { 'None', config.keymaps.none }, - } - for _, action in ipairs(actions) do - if action[2] then - if #parts > 0 then - table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' }) - end - table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' }) - end - end - if #parts > 0 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_lines = { parts }, - virt_lines_above = true, - }) - end + 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 @@ -154,11 +118,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictOurs', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictOursNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end @@ -167,7 +131,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_base + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) for line = region.base_start, region.base_end - 1 do @@ -175,11 +139,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictBase', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictBaseNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end end @@ -188,7 +152,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_sep + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) for line = region.theirs_start, region.theirs_end - 1 do @@ -196,11 +160,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictTheirs', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictTheirsNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end @@ -208,17 +172,14 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_theirs + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) if config.show_virtual_text then - local theirs_label = get_virtual_text_label('theirs', config) - if theirs_label then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) - end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) end end end @@ -238,7 +199,7 @@ end ---@param bufnr integer ---@param region diffs.ConflictRegion ---@param replacement string[] -function M.replace_region(bufnr, region, replacement) +local function replace_region(bufnr, region, replacement) vim.api.nvim_buf_set_lines( bufnr, region.marker_ours, @@ -250,7 +211,7 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig -function M.refresh(bufnr, config) +local function refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) @@ -283,8 +244,8 @@ function M.resolve_ours(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - M.replace_region(bufnr, region, lines) - M.refresh(bufnr, config) + replace_region(bufnr, region, lines) + refresh(bufnr, config) end ---@param bufnr integer @@ -301,8 +262,8 @@ function M.resolve_theirs(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - M.replace_region(bufnr, region, lines) - M.refresh(bufnr, config) + replace_region(bufnr, region, lines) + refresh(bufnr, config) end ---@param bufnr integer @@ -327,8 +288,8 @@ function M.resolve_both(bufnr, config) for _, l in ipairs(theirs) do table.insert(combined, l) end - M.replace_region(bufnr, region, combined) - M.refresh(bufnr, config) + replace_region(bufnr, region, combined) + refresh(bufnr, config) end ---@param bufnr integer @@ -344,8 +305,8 @@ function M.resolve_none(bufnr, config) if not region then return end - M.replace_region(bufnr, region, {}) - M.refresh(bufnr, config) + replace_region(bufnr, region, {}) + refresh(bufnr, config) end ---@param bufnr integer @@ -362,7 +323,6 @@ function M.goto_next(bufnr) return end end - vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) end @@ -380,7 +340,6 @@ function M.goto_prev(bufnr) return end end - vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) end @@ -458,7 +417,7 @@ function M.attach(bufnr, config) if not attached_buffers[bufnr] then return true end - M.refresh(bufnr, config) + refresh(bufnr, config) end, }) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index d4c3782..a588a22 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,65 +26,20 @@ function M.get_section_at_line(bufnr, lnum) return nil end ----@param s string ----@return string -local function unquote(s) - if s:sub(1, 1) ~= '"' then - return s - end - local inner = s:sub(2, -2) - local result = {} - local i = 1 - while i <= #inner do - if inner:sub(i, i) == '\\' and i < #inner then - local next_char = inner:sub(i + 1, i + 1) - if next_char == 'n' then - table.insert(result, '\n') - i = i + 2 - elseif next_char == 't' then - table.insert(result, '\t') - i = i + 2 - elseif next_char == '"' then - table.insert(result, '"') - i = i + 2 - elseif next_char == '\\' then - table.insert(result, '\\') - i = i + 2 - elseif next_char:match('%d') then - local oct = inner:match('^(%d%d%d)', i + 1) - if oct then - table.insert(result, string.char(tonumber(oct, 8))) - i = i + 4 - else - table.insert(result, next_char) - i = i + 2 - end - else - table.insert(result, next_char) - i = i + 2 - end - else - table.insert(result, inner:sub(i, i)) - i = i + 1 - end - end - return table.concat(result) -end - ---@param line string ----@return string?, string?, string? +---@return string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' + return vim.trim(new), vim.trim(old) end - local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') - if status and filename then - return unquote(vim.trim(filename)), nil, status + local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') + if filename then + return vim.trim(filename), nil end - return nil, nil, nil + return nil, nil end ---@param line string @@ -102,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string?, string? +---@return string?, diffs.FugitiveSection, boolean, string? function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil, false, nil, nil + return nil, nil, false, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true, nil, nil + return nil, section_header, true, nil end - local filename, old_filename, status = parse_file_line(current_line) + local filename, old_filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false, old_filename, status + return filename, section, false, old_filename end local prefix = current_line:sub(1, 1) if prefix == '+' or prefix == '-' or prefix == ' ' then for i = lnum - 1, 1, -1 do local prev_line = lines[i] - filename, old_filename, status = parse_file_line(prev_line) + filename, old_filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename, status + return filename, section, false, old_filename end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -137,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false, nil, nil + return nil, nil, false, nil end ---@class diffs.HunkPosition @@ -195,7 +150,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -237,7 +192,6 @@ function M.diff_file_under_cursor(vertical) vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', - unmerged = status == 'U', old_filepath = old_filepath, hunk_position = hunk_position, }) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index 1aa6328..7695283 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -1,19 +1,13 @@ local M = {} -local repo_root_cache = {} - ---@param filepath string ---@return string? function M.get_repo_root(filepath) local dir = vim.fn.fnamemodify(filepath, ':h') - if repo_root_cache[dir] ~= nil then - return repo_root_cache[dir] - end local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) if vim.v.shell_error ~= 0 then return nil end - repo_root_cache[dir] = result[1] return result[1] end diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 8e80620..306b0e5 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,6 +3,38 @@ 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 @@ -10,9 +42,8 @@ local diff = require('diffs.diff') ---@param text string ---@param lang string ---@param context_lines? string[] ----@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities) +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') @@ -46,7 +77,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l local buf_sc = col_offset + sc local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax + 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, @@ -72,7 +103,6 @@ end ---@param line_map table ---@param col_offset integer ---@param covered_lines? table ----@param priorities diffs.PrioritiesConfig ---@return integer local function highlight_treesitter( bufnr, @@ -81,8 +111,7 @@ local function highlight_treesitter( lang, line_map, col_offset, - covered_lines, - priorities + covered_lines ) local code = table.concat(code_lines, '\n') if code == '' then @@ -123,7 +152,7 @@ local function highlight_treesitter( local buf_ec = ec + col_offset local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) - or priorities.syntax + or PRIORITY_SYNTAX pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -190,17 +219,8 @@ end ---@param code_lines string[] ---@param covered_lines? table ---@param leading_offset? integer ----@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_vim_syntax( - bufnr, - ns, - hunk, - code_lines, - covered_lines, - leading_offset, - priorities -) +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) local ft = hunk.ft if not ft then return 0 @@ -218,7 +238,7 @@ local function highlight_vim_syntax( local spans = {} - pcall(vim.api.nvim_buf_call, scratch, function() + vim.api.nvim_buf_call(scratch, function() vim.cmd('setlocal syntax=' .. ft) vim.cmd('redraw') @@ -236,19 +256,18 @@ local function highlight_vim_syntax( spans = M.coalesce_syntax_spans(query_fn, code_lines) end) - pcall(vim.api.nvim_buf_delete, scratch, { force = true }) + vim.api.nvim_buf_delete(scratch, { force = true }) local hunk_line_count = #hunk.lines - local col_off = (hunk.prefix_width or 1) - 1 local extmark_count = 0 for _, span in ipairs(spans) do local adj = span.line - leading_offset if adj >= 1 and adj <= hunk_line_count then local buf_line = hunk.start_line + adj - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, { - end_col = span.col_end + col_off, + 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 = priorities.syntax, + priority = PRIORITY_SYNTAX, }) extmark_count = extmark_count + 1 if covered_lines then @@ -265,8 +284,6 @@ end ---@param hunk diffs.Hunk ---@param opts diffs.HunkOpts function M.highlight_hunk(bufnr, ns, hunk, opts) - local p = opts.highlights.priorities - local pw = hunk.prefix_width or 1 local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled @@ -286,6 +303,21 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local covered_lines = {} + local ctx_cfg = opts.highlights.context + local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0 + local leading = {} + local trailing = {} + if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then + local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename) + local lead_from = math.max(1, hunk.file_new_start - context) + local lead_count = hunk.file_new_start - lead_from + if lead_count > 0 then + leading = read_line_range(filepath, lead_from, lead_count) + end + local trail_from = hunk.file_new_start + (hunk.file_new_count or 0) + trailing = read_line_range(filepath, trail_from, context) + end + local extmark_count = 0 if use_ts then ---@type string[] @@ -297,17 +329,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local old_map = {} - for i, line in ipairs(hunk.lines) do - local prefix = line:sub(1, pw) - local stripped = line:sub(pw + 1) - local buf_line = hunk.start_line + i - 1 - local has_add = prefix:find('+', 1, true) ~= nil - local has_del = prefix:find('-', 1, true) ~= nil + for _, pad_line in ipairs(leading) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + end - if has_add and not has_del then + 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 has_del and not has_add then + elseif prefix == '-' then old_map[#old_code] = buf_line table.insert(old_code, stripped) else @@ -317,17 +352,21 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - extmark_count = - highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p) + 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, pw, covered_lines, p) + + 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 = p.clear, + priority = PRIORITY_CLEAR, }) local header_extmarks = highlight_text( bufnr, @@ -336,8 +375,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hunk.header_context_col, hunk.header_context, hunk.lang, - new_code, - p + new_code ) if header_extmarks > 0 then dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) @@ -347,10 +385,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then ---@type string[] local code_lines = {} - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(pw + 1)) + for _, pad_line in ipairs(leading) do + table.insert(code_lines, pad_line) end - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) + 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 @@ -365,13 +409,13 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) end ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra - if intra_cfg and intra_cfg.enabled and pw == 1 and #hunk.lines <= intra_cfg.max_lines then + if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) if intra then @@ -405,35 +449,25 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line - local prefix = line:sub(1, pw) - local has_add = prefix:find('+', 1, true) ~= nil - local has_del = prefix:find('-', 1, true) ~= nil - local is_diff_line = has_add or has_del - local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil - local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil + local prefix = line:sub(1, 1) - local is_marker = false - if pw > 1 and line_hl and not prefix:find('[^+]') then - local content = line:sub(pw + 1) - is_marker = content:match('^<<<<<<<') - or content:match('^=======') - or content:match('^>>>>>>>') - or content:match('^|||||||') - end + local is_diff_line = prefix == '+' or prefix == '-' + local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil + local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil if opts.hide_prefix then local virt_hl = (opts.highlights.background and line_hl) or nil pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - virt_text = { { string.rep(' ', pw), virt_hl } }, + virt_text = { { ' ', virt_hl } }, virt_text_pos = 'overlay', }) end - if line_len > pw and covered_lines[buf_line] then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { + 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 = 'DiffsClear', - priority = p.clear, + priority = PRIORITY_CLEAR, }) end @@ -442,26 +476,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end_row = buf_line + 1, hl_group = line_hl, hl_eol = true, - priority = p.line_bg, + 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 = p.line_bg, + priority = PRIORITY_LINE_BG, }) end end - if is_marker and line_len > pw then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { - end_col = line_len, - hl_group = 'DiffsConflictMarker', - priority = p.char_bg, - }) - end - if char_spans_by_line[i] then - local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText' + local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' for _, span in ipairs(char_spans_by_line[i]) do dbg( 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', @@ -475,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 = p.char_bg, + 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 00dddca..cdc29d1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -15,12 +15,6 @@ ---@field enabled boolean ---@field lines integer ----@class diffs.PrioritiesConfig ----@field clear integer ----@field syntax integer ----@field line_bg integer ----@field char_bg integer - ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean @@ -30,7 +24,6 @@ ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig ----@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false @@ -48,9 +41,6 @@ ---@field enabled boolean ---@field disable_diagnostics boolean ---@field show_virtual_text boolean ----@field format_virtual_text? fun(side: string, keymap: string|false): string? ----@field show_actions boolean ----@field priority integer ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config @@ -129,12 +119,6 @@ local default_config = { algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, }, fugitive = { horizontal = 'du', @@ -144,8 +128,6 @@ local default_config = { enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, - priority = 200, keymaps = { ours = 'doo', theirs = 'dot', @@ -218,9 +200,7 @@ local function create_debounced_highlight(bufnr) timer = nil t:close() end - if vim.api.nvim_buf_is_valid(bufnr) then - highlight_buffer(bufnr) - end + highlight_buffer(bufnr) end) ) end @@ -294,7 +274,6 @@ local function compute_highlight_groups() vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs }) vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true }) - vim.api.nvim_set_hl(0, 'DiffsConflictActions', { default = true, fg = 0x808080 }) vim.api.nvim_set_hl( 0, 'DiffsConflictOursNr', @@ -343,7 +322,6 @@ local function init() ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, ['highlights.intra'] = { opts.highlights.intra, 'table', true }, - ['highlights.priorities'] = { opts.highlights.priorities, 'table', true }, }) if opts.highlights.context then @@ -384,15 +362,6 @@ local function init() ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, }) end - - if opts.highlights.priorities then - vim.validate({ - ['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true }, - ['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true }, - ['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true }, - ['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true }, - }) - end end if opts.fugitive then @@ -419,9 +388,6 @@ local function init() ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, - ['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true }, - ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true }, - ['conflict.priority'] = { opts.conflict.priority, 'number', true }, ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, }) @@ -483,17 +449,6 @@ local function init() then error('diffs: highlights.blend_alpha must be >= 0 and <= 1') end - if opts.highlights and opts.highlights.priorities then - for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do - local v = opts.highlights.priorities[key] - if v and v < 0 then - error('diffs: highlights.priorities.' .. key .. ' must be >= 0') - end - end - end - if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then - error('diffs: conflict.priority must be >= 0') - end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua index 8376925..5b3254b 100644 --- a/lua/diffs/lib.lua +++ b/lua/diffs/lib.lua @@ -8,9 +8,6 @@ local cached_handle = nil ---@type boolean local download_in_progress = false ----@type fun(handle: table?)[] -local pending_callbacks = {} - ---@return string local function get_os() local os_name = jit.os:lower() @@ -167,10 +164,9 @@ function M.ensure(callback) return end - table.insert(pending_callbacks, callback) - if download_in_progress then - dbg('download already in progress, queued callback') + dbg('download already in progress') + callback(nil) return end @@ -196,25 +192,21 @@ function M.ensure(callback) vim.system(cmd, {}, function(result) download_in_progress = false vim.schedule(function() - local handle = nil if result.code ~= 0 then vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) dbg('curl failed: %s', result.stderr or '') - else - local f = io.open(version_path(), 'w') - if f then - f:write(EXPECTED_VERSION) - f:close() - end - vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) - handle = M.load() + callback(nil) + return end - local cbs = pending_callbacks - pending_callbacks = {} - for _, cb in ipairs(cbs) do - cb(handle) + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() end + + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + callback(M.load()) end) end) end diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua deleted file mode 100644 index daf72ac..0000000 --- a/lua/diffs/merge.lua +++ /dev/null @@ -1,418 +0,0 @@ -local M = {} - -local conflict = require('diffs.conflict') - -local ns = vim.api.nvim_create_namespace('diffs-merge') - ----@type table> -local resolved_hunks = {} - ----@class diffs.MergeHunkInfo ----@field index integer ----@field start_line integer ----@field end_line integer ----@field del_lines string[] ----@field add_lines string[] - ----@param bufnr integer ----@return diffs.MergeHunkInfo[] -function M.parse_hunks(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local hunks = {} - local current = nil - - for i, line in ipairs(lines) do - local idx = i - 1 - if line:match('^@@') then - if current then - current.end_line = idx - 1 - table.insert(hunks, current) - end - current = { - index = #hunks + 1, - start_line = idx, - end_line = idx, - del_lines = {}, - add_lines = {}, - } - elseif current then - local prefix = line:sub(1, 1) - if prefix == '-' then - table.insert(current.del_lines, line:sub(2)) - elseif prefix == '+' then - table.insert(current.add_lines, line:sub(2)) - elseif prefix ~= ' ' and prefix ~= '\\' then - current.end_line = idx - 1 - table.insert(hunks, current) - current = nil - end - if current then - current.end_line = idx - end - end - end - - if current then - table.insert(hunks, current) - end - - return hunks -end - ----@param bufnr integer ----@return diffs.MergeHunkInfo? -function M.find_hunk_at_cursor(bufnr) - local hunks = M.parse_hunks(bufnr) - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - for _, hunk in ipairs(hunks) do - if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then - return hunk - end - end - - return nil -end - ----@param hunk diffs.MergeHunkInfo ----@param working_bufnr integer ----@return diffs.ConflictRegion? -function M.match_hunk_to_conflict(hunk, working_bufnr) - local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - local regions = conflict.parse(working_lines) - - for _, region in ipairs(regions) do - local ours_lines = {} - for line = region.ours_start + 1, region.ours_end do - table.insert(ours_lines, working_lines[line]) - end - - if #ours_lines == #hunk.del_lines then - local match = true - for j = 1, #ours_lines do - if ours_lines[j] ~= hunk.del_lines[j] then - match = false - break - end - end - if match then - return region - end - end - end - - return nil -end - ----@param diff_bufnr integer ----@return integer? -function M.get_or_load_working_buf(diff_bufnr) - local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path') - if not ok or not working_path then - return nil - end - - local existing = vim.fn.bufnr(working_path) - if existing ~= -1 then - return existing - end - - local bufnr = vim.fn.bufadd(working_path) - vim.fn.bufload(bufnr) - return bufnr -end - ----@param diff_bufnr integer ----@param hunk_index integer -local function mark_resolved(diff_bufnr, hunk_index) - if not resolved_hunks[diff_bufnr] then - resolved_hunks[diff_bufnr] = {} - end - resolved_hunks[diff_bufnr][hunk_index] = true -end - ----@param diff_bufnr integer ----@param hunk_index integer ----@return boolean -function M.is_resolved(diff_bufnr, hunk_index) - return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false -end - ----@param diff_bufnr integer ----@param hunk diffs.MergeHunkInfo -local function add_resolved_virtual_text(diff_bufnr, hunk) - pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, { - virt_text = { { ' (resolved)', 'Comment' } }, - virt_text_pos = 'eol', - }) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_ours(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) - conflict.replace_region(working_bufnr, region, lines) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_theirs(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local lines = - vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) - conflict.replace_region(working_bufnr, region, lines) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_both(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) - local theirs = - vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) - local combined = {} - for _, l in ipairs(ours) do - table.insert(combined, l) - end - for _, l in ipairs(theirs) do - table.insert(combined, l) - end - conflict.replace_region(working_bufnr, region, combined) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_none(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - conflict.replace_region(working_bufnr, region, {}) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer -function M.goto_next(bufnr) - local hunks = M.parse_hunks(bufnr) - if #hunks == 0 then - return - end - - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - local candidates = {} - for _, hunk in ipairs(hunks) do - if not M.is_resolved(bufnr, hunk.index) then - if M.match_hunk_to_conflict(hunk, working_bufnr) then - table.insert(candidates, hunk) - end - end - end - - if #candidates == 0 then - return - end - - for _, hunk in ipairs(candidates) do - if hunk.start_line > cursor_line then - vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 }) - return - end - end - - vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - local hunks = M.parse_hunks(bufnr) - if #hunks == 0 then - return - end - - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - local candidates = {} - for _, hunk in ipairs(hunks) do - if not M.is_resolved(bufnr, hunk.index) then - if M.match_hunk_to_conflict(hunk, working_bufnr) then - table.insert(candidates, hunk) - end - end - end - - if #candidates == 0 then - return - end - - for i = #candidates, 1, -1 do - if candidates[i].start_line < cursor_line then - vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 }) - return - end - end - - vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -local function apply_hunk_hints(bufnr, config) - if not config.show_virtual_text then - return - end - - local hunks = M.parse_hunks(bufnr) - for _, hunk in ipairs(hunks) do - if M.is_resolved(bufnr, hunk.index) then - add_resolved_virtual_text(bufnr, hunk) - else - local parts = {} - local actions = { - { 'current', config.keymaps.ours }, - { 'incoming', config.keymaps.theirs }, - { 'both', config.keymaps.both }, - { 'none', config.keymaps.none }, - } - for _, action in ipairs(actions) do - if action[2] then - if #parts > 0 then - table.insert(parts, { ' | ', 'Comment' }) - end - table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' }) - end - end - if #parts > 0 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, { - virt_text = parts, - virt_text_pos = 'eol', - }) - end - end - end -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.setup_keymaps(bufnr, config) - resolved_hunks[bufnr] = nil - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local km = config.keymaps - - local maps = { - { km.ours, '(diffs-merge-ours)' }, - { km.theirs, '(diffs-merge-theirs)' }, - { km.both, '(diffs-merge-both)' }, - { km.none, '(diffs-merge-none)' }, - { km.next, '(diffs-merge-next)' }, - { km.prev, '(diffs-merge-prev)' }, - } - - for _, map in ipairs(maps) do - if map[1] then - vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) - end - end - - apply_hunk_hints(bufnr, config) - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - resolved_hunks[bufnr] = nil - end, - }) -end - ----@return integer -function M.get_namespace() - return ns -end - -return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 6a04d38..43eb1f6 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -12,7 +12,6 @@ ---@field file_old_count integer? ---@field file_new_start integer? ---@field file_new_count integer? ----@field prefix_width integer ---@field repo_root string? local M = {} @@ -134,8 +133,6 @@ function M.parse_buffer(bufnr) local hunk_lines = {} ---@type integer? local hunk_count = nil - ---@type integer - local hunk_prefix_width = 1 ---@type integer? local header_start = nil ---@type string[] @@ -159,7 +156,6 @@ function M.parse_buffer(bufnr) header_context = hunk_header_context, header_context_col = hunk_header_context_col, lines = hunk_lines, - prefix_width = hunk_prefix_width, file_old_start = file_old_start, file_old_count = file_old_count, file_new_start = file_new_start, @@ -183,7 +179,7 @@ function M.parse_buffer(bufnr) end for i, line in ipairs(lines) do - local filename = line:match('^[MADRCU%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') + local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') if filename then flush_hunk() current_filename = filename @@ -195,33 +191,22 @@ function M.parse_buffer(bufnr) dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) end hunk_count = 0 - hunk_prefix_width = 1 header_start = i header_lines = {} - elseif line:match('^@@+') then + elseif line:match('^@@.-@@') then flush_hunk() hunk_start = i - local at_prefix = line:match('^(@@+)') - hunk_prefix_width = #at_prefix - 1 - if #at_prefix == 2 then - local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - if hs then - file_old_start = tonumber(hs) - file_old_count = tonumber(hc) or 1 - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 - end - else - local hs2, hc2 = line:match('%+(%d+),?(%d*) @@') - if hs2 then - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 - end + 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 at_end, context = line:match('^(@@+.-@@+%s*)(.*)') + local prefix, context = line:match('^(@@.-@@%s*)(.*)') if context and context ~= '' then hunk_header_context = context - hunk_header_context_col = #at_end + hunk_header_context_col = #prefix end if hunk_count then hunk_count = hunk_count + 1 diff --git a/plugin/diffs.lua b/plugin/diffs.lua index face556..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -82,28 +82,3 @@ end, { desc = 'Jump to next conflict' }) vim.keymap.set('n', '(diffs-conflict-prev)', function() require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) end, { desc = 'Jump to previous conflict' }) - -local function merge_action(fn) - local bufnr = vim.api.nvim_get_current_buf() - local config = require('diffs').get_conflict_config() - fn(bufnr, config) -end - -vim.keymap.set('n', '(diffs-merge-ours)', function() - merge_action(require('diffs.merge').resolve_ours) -end, { desc = 'Accept ours in merge diff' }) -vim.keymap.set('n', '(diffs-merge-theirs)', function() - merge_action(require('diffs.merge').resolve_theirs) -end, { desc = 'Accept theirs in merge diff' }) -vim.keymap.set('n', '(diffs-merge-both)', function() - merge_action(require('diffs.merge').resolve_both) -end, { desc = 'Accept both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-none)', function() - merge_action(require('diffs.merge').resolve_none) -end, { desc = 'Reject both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-next)', function() - require('diffs.merge').goto_next(vim.api.nvim_get_current_buf()) -end, { desc = 'Jump to next conflict hunk' }) -vim.keymap.set('n', '(diffs-merge-prev)', function() - require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf()) -end, { desc = 'Jump to previous conflict hunk' }) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 1bf9e61..daba5d5 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -40,78 +40,6 @@ describe('commands', function() end) end) - describe('filter_combined_diffs', function() - it('strips diff --cc entries entirely', function() - local lines = { - 'diff --cc main.lua', - 'index d13ab94,b113aee..0000000', - '--- a/main.lua', - '+++ b/main.lua', - '@@@ -1,7 -1,7 +1,11 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '++=======', - '+ return 2', - '++>>>>>>> theirs', - ' end', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(0, #result) - end) - - it('preserves diff --git entries', function() - local lines = { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(8, #result) - assert.are.same(lines, result) - end) - - it('strips combined but keeps unified in mixed output', function() - local lines = { - 'diff --cc conflict.lua', - 'index aaa,bbb..000', - '@@@ -1,1 -1,1 +1,5 @@@', - '++<<<<<<< HEAD', - 'diff --git a/clean.lua b/clean.lua', - '--- a/clean.lua', - '+++ b/clean.lua', - '@@ -1,1 +1,1 @@', - '-old', - '+new', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(6, #result) - assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1]) - assert.are.equal('+new', result[6]) - end) - - it('returns empty for empty input', function() - local result = commands.filter_combined_diffs({}) - assert.are.equal(0, #result) - end) - - it('returns empty when all entries are combined', function() - local lines = { - 'diff --cc a.lua', - 'some content', - 'diff --cc b.lua', - 'more content', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(0, #result) - end) - end) - describe('find_hunk_line', function() it('finds matching @@ header and returns target line', function() local diff_lines = { diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index dd190c9..75eac23 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -6,7 +6,6 @@ local function default_config(overrides) enabled = true, disable_diagnostics = false, show_virtual_text = true, - show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', @@ -235,6 +234,29 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('does not apply virtual text when disabled', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_virtual_text = false })) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + it('applies number_hl_group to content lines', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -509,33 +531,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('goto_next notifies on wrap-around', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to first conflict') then - notified = true - end - end - - conflict.goto_next(bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(bufnr) - end) - it('goto_prev jumps to previous conflict', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -580,33 +575,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('goto_prev notifies on wrap-around', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to last conflict') then - notified = true - end - end - - conflict.goto_prev(bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(bufnr) - end) - it('goto_next does nothing with no conflicts', function() local bufnr = create_file_buffer({ 'normal line' }) vim.api.nvim_set_current_buf(bufnr) @@ -717,158 +685,4 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) end) - - describe('virtual text formatting', function() - after_each(function() - conflict.detach(vim.api.nvim_get_current_buf()) - end) - - it('default labels show current and incoming without keymaps', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config()) - - local extmarks = get_extmarks(bufnr) - local labels = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - table.insert(labels, mark[4].virt_text[1][1]) - end - end - assert.are.equal(2, #labels) - assert.are.equal(' (current)', labels[1]) - assert.are.equal(' (incoming)', labels[2]) - - helpers.delete_buffer(bufnr) - end) - - it('uses custom format_virtual_text function', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ - format_virtual_text = function(side) - return side == 'ours' and 'OURS' or 'THEIRS' - end, - }) - ) - - local extmarks = get_extmarks(bufnr) - local labels = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - table.insert(labels, mark[4].virt_text[1][1]) - end - end - assert.are.equal(2, #labels) - assert.are.equal(' (OURS)', labels[1]) - assert.are.equal(' (THEIRS)', labels[2]) - - helpers.delete_buffer(bufnr) - end) - - it('hides label when format_virtual_text returns nil', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ - format_virtual_text = function() - return nil - end, - }) - ) - - local extmarks = get_extmarks(bufnr) - local virt_text_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - virt_text_count = virt_text_count + 1 - end - end - assert.are.equal(0, virt_text_count) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('action lines', function() - after_each(function() - conflict.detach(vim.api.nvim_get_current_buf()) - end) - - it('adds virt_lines when show_actions is true', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config({ show_actions = true })) - - local extmarks = get_extmarks(bufnr) - local virt_lines_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_lines then - virt_lines_count = virt_lines_count + 1 - end - end - assert.are.equal(1, virt_lines_count) - - helpers.delete_buffer(bufnr) - end) - - it('omits disabled keymaps from action line', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ show_actions = true, keymaps = { both = false, none = false } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_lines then - local line = mark[4].virt_lines[1] - local text = '' - for _, chunk in ipairs(line) do - text = text .. chunk[1] - end - assert.is_truthy(text:find('Current')) - assert.is_truthy(text:find('Incoming')) - assert.is_falsy(text:find('Both')) - assert.is_falsy(text:find('None')) - end - end - - helpers.delete_buffer(bufnr) - end) - end) end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 95b40a3..244e1b7 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -87,6 +87,28 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('parses added file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A newfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses deleted file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'D oldfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('oldfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', @@ -135,6 +157,28 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('handles renamed file in subdirectory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old.lua -> src/new.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new.lua', filename) + assert.equals('src/old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file moved to different directory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old/file.lua -> new/file.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new/file.lua', filename) + assert.equals('old/file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() local buf = create_status_buffer({ 'Staged (1)', @@ -146,54 +190,77 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('unquotes git-quoted filenames with spaces', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M "path with spaces/file.lua"', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('path with spaces/file.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes escaped quotes in filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M "file\\"name.lua"', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('file"name.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes octal escapes in filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M "\\303\\251le.lua"', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('\195\169le.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('passes through unquoted filenames unchanged', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M normal.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('normal.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes renamed files with quotes', function() + it('handles double extensions', function() local buf = create_status_buffer({ 'Staged (1)', - 'R100 "old name.lua" -> "new name.lua"', + 'M test.spec.lua', }) local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new name.lua', filename) - assert.equals('old name.lua', old_filename) + assert.equals('test.spec.lua', filename) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles hyphenated filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M my-component-test.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('my-component-test.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles underscores and numbers', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A test_file_123.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test_file_123.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles dotfiles', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M .gitignore', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('.gitignore', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed with complex names', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old-file.spec.lua -> src/new-file.spec.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new-file.spec.lua', filename) + assert.equals('src/old-file.spec.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles deeply nested paths', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M lua/diffs/ui/components/diff-view.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('lua/diffs/ui/components/diff-view.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses untracked file', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? untracked.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('untracked.lua', filename) + assert.equals('untracked', section) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -254,6 +321,30 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('detects section header for Unstaged', function() + local buf = create_status_buffer({ + 'Unstaged (3)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('unstaged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Untracked', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? newfile.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('untracked', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('returns is_header=false for file lines', function() local buf = create_status_buffer({ 'Staged (1)', @@ -315,6 +406,22 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('returns hunk header and offset for - line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('returns hunk header and offset for context line', function() local buf = create_status_buffer({ 'Unstaged (1)', diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 42cd0d1..0b9051e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -13,7 +13,6 @@ describe('highlight', function() vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg }) vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg }) - vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) end) local function create_buffer(lines) @@ -52,12 +51,6 @@ describe('highlight', function() algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, }, } if overrides then @@ -71,6 +64,27 @@ describe('highlight', function() return opts end + it('applies extmarks for lua code', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -176,6 +190,36 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('highlights header context when enabled', function() + local bufnr = create_buffer({ + '@@ -10,3 +10,4 @@ function hello()', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function hello()', + header_context_col = 18, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_header_extmark = false + for _, mark in ipairs(extmarks) do + if mark[2] == 0 then + has_header_extmark = true + break + end + end + assert.is_true(has_header_extmark) + delete_buffer(bufnr) + end) + it('highlights function keyword in header context', function() local bufnr = create_buffer({ '@@ -5,3 +5,4 @@ function M.setup()', @@ -236,6 +280,44 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('handles empty hunk lines', function() + local bufnr = create_buffer({ + '@@ -1,0 +1,0 @@', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = {}, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + end) + delete_buffer(bufnr) + end) + + it('handles code that is just whitespace', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' ', + '+ ', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' ', '+ ' }, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + end) + delete_buffer(bufnr) + end) + it('applies overlay extmarks when hide_prefix enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -263,6 +345,33 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not apply overlay extmarks when hide_prefix disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false })) + + local extmarks = get_extmarks(bufnr) + local overlay_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text_pos == 'overlay' then + overlay_count = overlay_count + 1 + end + end + assert.are.equal(0, overlay_count) + delete_buffer(bufnr) + end) + it('applies DiffAdd background to + lines when background enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -329,6 +438,39 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not apply background when background disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = false } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_line_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then + has_line_hl = true + break + end + end + assert.is_false(has_line_hl) + delete_buffer(bufnr) + end) + it('applies number_hl_group when gutter enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -362,6 +504,72 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not apply number_hl_group when gutter disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, gutter = false } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_number_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].number_hl_group then + has_number_hl = true + break + end + end + assert.is_false(has_number_hl) + delete_buffer(bufnr) + end) + + it('skips treesitter highlights when treesitter disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_ts_highlight = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then + has_ts_highlight = true + break + end + end + assert.is_false(has_ts_highlight) + delete_buffer(bufnr) + end) + it('still applies background when treesitter disabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -446,6 +654,40 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('skips vim fallback when vim.enabled is false', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'abap', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { vim = { enabled = false } } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_syntax_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then + has_syntax_hl = true + break + end + end + assert.is_false(has_syntax_hl) + delete_buffer(bufnr) + end) + it('respects vim.max_lines', function() local lines = { '@@ -1,100 +1,101 @@' } local hunk_lines = {} @@ -658,6 +900,92 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('line bg priority > DiffsClear priority', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + '-local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local clear_priority = nil + local line_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' then + clear_priority = d.priority + end + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + end + assert.is_not_nil(clear_priority) + assert.is_not_nil(line_bg_priority) + assert.is_true(line_bg_priority > clear_priority) + delete_buffer(bufnr) + end) + + it('char-level extmarks have higher priority than line bg', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { + background = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local line_bg_priority = nil + local char_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + char_bg_priority = d.priority + end + end + assert.is_not_nil(line_bg_priority) + assert.is_not_nil(char_bg_priority) + assert.is_true(char_bg_priority > line_bg_priority) + delete_buffer(bufnr) + end) + it('creates char-level extmarks for changed characters', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) @@ -701,6 +1029,38 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not create char-level extmarks when intra disabled', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsAddText', d and d.hl_group) + assert.is_not_equal('DiffsDeleteText', d and d.hl_group) + end + delete_buffer(bufnr) + end) + it('does not create char-level extmarks for pure additions', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) @@ -797,6 +1157,142 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('context padding produces no extmarks on padding lines', function() + local repo_root = '/tmp/diffs-test-context' + vim.fn.mkdir(repo_root, 'p') + + local f = io.open(repo_root .. '/test.lua', 'w') + f:write('local M = {}\n') + f:write('function M.hello()\n') + f:write(' return "hi"\n') + f:write('end\n') + f:write('return M\n') + f:close() + + local bufnr = create_buffer({ + '@@ -3,1 +3,2 @@', + ' return "hi"', + '+"bye"', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' return "hi"', '+"bye"' }, + file_old_start = 3, + file_old_count = 1, + file_new_start = 3, + file_new_count = 2, + repo_root = repo_root, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local row = mark[2] + assert.is_true(row >= 1 and row <= 2) + end + + delete_buffer(bufnr) + os.remove(repo_root .. '/test.lua') + vim.fn.delete(repo_root, 'rf') + end) + + it('context disabled matches behavior without padding', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + file_new_start = 1, + file_new_count = 2, + repo_root = '/nonexistent', + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = false, lines = 0 } } }) + ) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('gracefully handles missing file for context padding', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + file_new_start = 1, + file_new_count = 2, + repo_root = '/nonexistent/path', + } + + assert.has_no.errors(function() + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) + ) + end) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('highlights treesitter injections', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+vim.cmd([[ echo 1 ]])', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_vim_capture = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then + has_vim_capture = true + break + end + end + assert.is_true(has_vim_capture) + delete_buffer(bufnr) + end) + it('includes captures from both base and injected languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -831,307 +1327,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('classifies all combined diff prefix types for background', function() - local bufnr = create_buffer({ - '@@@ -1,5 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local greeting = "hi"', - '++=======', - '+ return 2', - '++>>>>>>> feature', - ' end', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local greeting = "hi"', - '++=======', - '+ return 2', - '++>>>>>>> feature', - ' end', - }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bgs = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_eol then - line_bgs[mark[2]] = mark[4].hl_group - end - end - assert.is_nil(line_bgs[1]) - assert.are.equal('DiffsAdd', line_bgs[2]) - assert.are.equal('DiffsAdd', line_bgs[3]) - assert.are.equal('DiffsAdd', line_bgs[4]) - assert.are.equal('DiffsAdd', line_bgs[5]) - assert.are.equal('DiffsAdd', line_bgs[6]) - assert.are.equal('DiffsAdd', line_bgs[7]) - assert.is_nil(line_bgs[8]) - delete_buffer(bufnr) - end) - - it('conceals full 2-char prefix for all combined diff line types', function() - local bufnr = create_buffer({ - '@@@ -1,3 -1,3 +1,5 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true })) - - local extmarks = get_extmarks(bufnr) - local overlays = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text_pos == 'overlay' then - overlays[mark[2]] = mark[4].virt_text[1][1] - end - end - assert.are.equal(5, vim.tbl_count(overlays)) - for _, text in pairs(overlays) do - assert.are.equal(' ', text) - end - delete_buffer(bufnr) - end) - - it('places treesitter captures at col_offset 2 for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,2 @@@', - ' local x = 1', - ' +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local ts_marks = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then - table.insert(ts_marks, mark) - end - end - assert.is_true(#ts_marks > 0) - for _, mark in ipairs(ts_marks) do - assert.is_true(mark[3] >= 2) - end - delete_buffer(bufnr) - end) - - it('applies DiffsClear starting at col 2 for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,1 -1,1 +1,2 @@@', - ' local x = 1', - ' +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsClear' then - assert.are.equal(2, mark[3]) - end - end - delete_buffer(bufnr) - end) - - it('skips intra-line diffing for combined diffs', function() - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) - - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,3 @@@', - ' local x = 1', - ' +local y = 2', - '+ local y = 3', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2', '+ local y = 3' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, - }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsAddText', d and d.hl_group) - assert.is_not_equal('DiffsDeleteText', d and d.hl_group) - end - delete_buffer(bufnr) - end) - - it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function() - local bufnr = create_buffer({ - '@@@ -1,5 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - '+ local x = 1', - '++||||||| base', - '++=======', - ' +local y = 2', - '++>>>>>>> feature', - ' return M', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - '+ local x = 1', - '++||||||| base', - '++=======', - ' +local y = 2', - '++>>>>>>> feature', - ' return M', - }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, gutter = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bgs = {} - local gutter_hls = {} - local marker_text = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_eol then - line_bgs[mark[2]] = d.hl_group - end - if d and d.number_hl_group then - gutter_hls[mark[2]] = d.number_hl_group - end - if d and d.hl_group == 'DiffsConflictMarker' then - marker_text[mark[2]] = true - end - end - - assert.is_nil(line_bgs[1]) - assert.are.equal('DiffsAdd', line_bgs[2]) - assert.are.equal('DiffsAdd', line_bgs[3]) - assert.are.equal('DiffsAdd', line_bgs[4]) - assert.are.equal('DiffsAdd', line_bgs[5]) - assert.are.equal('DiffsAdd', line_bgs[6]) - assert.are.equal('DiffsAdd', line_bgs[7]) - assert.is_nil(line_bgs[8]) - - assert.is_nil(gutter_hls[1]) - assert.are.equal('DiffsAddNr', gutter_hls[2]) - assert.are.equal('DiffsAddNr', gutter_hls[3]) - assert.are.equal('DiffsAddNr', gutter_hls[4]) - assert.are.equal('DiffsAddNr', gutter_hls[5]) - assert.are.equal('DiffsAddNr', gutter_hls[6]) - assert.are.equal('DiffsAddNr', gutter_hls[7]) - assert.is_nil(gutter_hls[8]) - - assert.is_true(marker_text[2] ~= nil) - assert.is_nil(marker_text[3]) - assert.is_true(marker_text[4] ~= nil) - assert.is_true(marker_text[5] ~= nil) - assert.is_nil(marker_text[6]) - assert.is_true(marker_text[7] ~= nil) - delete_buffer(bufnr) - end) - - it('does not apply DiffsConflictMarker in unified diffs', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,4 @@', - ' local M = {}', - '+<<<<<<< HEAD', - '+local x = 1', - '+=======', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsConflictMarker', d and d.hl_group) - end - delete_buffer(bufnr) - end) - it('filters @spell and @nospell captures from injections', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -1191,7 +1386,6 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end @@ -1274,6 +1468,47 @@ describe('highlight', function() assert.are.equal(0, header_extmarks) delete_buffer(bufnr) end) + + it('does not apply header highlights when treesitter disabled', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 5, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + local opts = default_opts() + opts.highlights.treesitter.enabled = false + + highlight.highlight_hunk(bufnr, ns, hunk, opts) + + local extmarks = get_extmarks(bufnr) + local header_extmarks = 0 + for _, mark in ipairs(extmarks) do + if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then + header_extmarks = header_extmarks + 1 + end + end + assert.are.equal(0, header_extmarks) + delete_buffer(bufnr) + end) end) describe('extmark priority', function() @@ -1308,11 +1543,40 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end + it('uses priority 199 for code languages', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_priority_199 = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + if mark[4].priority == 199 then + has_priority_199 = true + break + end + end + end + assert.is_true(has_priority_199) + delete_buffer(bufnr) + end) + it('uses treesitter priority for diff language', function() local bufnr = create_buffer({ 'diff --git a/test.lua b/test.lua', diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua deleted file mode 100644 index 6cb9f7e..0000000 --- a/spec/merge_spec.lua +++ /dev/null @@ -1,815 +0,0 @@ -local helpers = require('spec.helpers') -local merge = require('diffs.merge') - -local function default_config(overrides) - local cfg = { - enabled = true, - disable_diagnostics = false, - show_virtual_text = true, - show_actions = false, - keymaps = { - ours = 'doo', - theirs = 'dot', - both = 'dob', - none = 'don', - next = ']x', - prev = '[x', - }, - } - if overrides then - cfg = vim.tbl_deep_extend('force', cfg, overrides) - end - return cfg -end - -local function create_diff_buffer(lines, working_path) - local bufnr = helpers.create_buffer(lines) - if working_path then - vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path) - end - return bufnr -end - -local function create_working_buffer(lines, name) - local bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - if name then - vim.api.nvim_buf_set_name(bufnr, name) - end - return bufnr -end - -describe('merge', function() - describe('parse_hunks', function() - it('parses a single hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal(3, hunks[1].start_line) - assert.are.equal(7, hunks[1].end_line) - assert.are.same({ 'local x = 1' }, hunks[1].del_lines) - assert.are.same({ 'local x = 2' }, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('parses multiple hunks', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - '@@ -10,3 +10,3 @@', - ' function M.foo()', - '- return 1', - '+ return 2', - ' end', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(2, #hunks) - assert.are.equal(3, hunks[1].start_line) - assert.are.equal(8, hunks[2].start_line) - - helpers.delete_buffer(bufnr) - end) - - it('parses add-only hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.same({}, hunks[1].del_lines) - assert.are.same({ 'local new = true' }, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('parses delete-only hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,2 @@', - ' local M = {}', - '-local old = false', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.same({ 'local old = false' }, hunks[1].del_lines) - assert.are.same({}, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('returns empty for buffer with no hunks', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(0, #hunks) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('match_hunk_to_conflict', function() - it('matches hunk to conflict region', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_match.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local x = 1' }, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(0, region.marker_ours) - - helpers.delete_buffer(working_bufnr) - end) - - it('returns nil for auto-merged content', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_auto.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local y = 3' }, - add_lines = { 'local y = 4' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_nil(region) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches with empty ours section', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_empty_ours.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 5, - del_lines = {}, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches correct region among multiple conflicts', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, '/tmp/diffs_test_multi.lua') - - local hunk = { - index = 2, - start_line = 8, - end_line = 12, - del_lines = { 'local b = 3' }, - add_lines = { 'local b = 4' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(6, region.marker_ours) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches with diff3 format', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - 'local x = 0', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_diff3.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local x = 1' }, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(2, region.marker_base) - - helpers.delete_buffer(working_bufnr) - end) - end) - - describe('resolution', function() - local diff_bufnr, working_bufnr - - local function setup_buffers() - local working_path = '/tmp/diffs_test_resolve.lua' - working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - diff_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(diff_bufnr) - end - - local function cleanup() - helpers.delete_buffer(diff_bufnr) - helpers.delete_buffer(working_bufnr) - end - - it('resolve_ours keeps ours content in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 1', lines[1]) - - cleanup() - end) - - it('resolve_theirs keeps theirs content in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_theirs(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 2', lines[1]) - - cleanup() - end) - - it('resolve_both keeps ours then theirs in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_both(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(2, #lines) - assert.are.equal('local x = 1', lines[1]) - assert.are.equal('local x = 2', lines[2]) - - cleanup() - end) - - it('resolve_none removes entire block from working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_none(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('', lines[1]) - - cleanup() - end) - - it('tracks resolved hunks', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - assert.is_false(merge.is_resolved(diff_bufnr, 1)) - merge.resolve_ours(diff_bufnr, default_config()) - assert.is_true(merge.is_resolved(diff_bufnr, 1)) - - cleanup() - end) - - it('adds virtual text for resolved hunks', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local extmarks = - vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local has_resolved_text = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - for _, chunk in ipairs(mark[4].virt_text) do - if chunk[1]:match('resolved') then - has_resolved_text = true - end - end - end - end - assert.is_true(has_resolved_text) - - cleanup() - end) - - it('notifies when hunk is already resolved', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('already resolved') then - notified = true - end - end - - merge.resolve_ours(diff_bufnr, default_config()) - vim.notify = orig_notify - - assert.is_true(notified) - - cleanup() - end) - - it('notifies when hunk does not match a conflict', function() - local working_path = '/tmp/diffs_test_no_conflict.lua' - local w_bufnr = create_working_buffer({ - 'local y = 1', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('does not correspond') then - notified = true - end - end - - merge.resolve_ours(d_bufnr, default_config()) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('navigation', function() - it('goto_next jumps to next conflict hunk', function() - local working_path = '/tmp/diffs_test_nav.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - merge.goto_next(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - merge.goto_next(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_next wraps around', function() - local working_path = '/tmp/diffs_test_wrap.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 6, 0 }) - - merge.goto_next(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_next notifies on wrap-around', function() - local working_path = '/tmp/diffs_test_wrap_notify.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 6, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to first hunk') then - notified = true - end - end - - merge.goto_next(d_bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev jumps to previous conflict hunk', function() - local working_path = '/tmp/diffs_test_prev.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 9, 0 }) - - merge.goto_prev(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - merge.goto_prev(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev wraps around', function() - local working_path = '/tmp/diffs_test_prev_wrap.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - merge.goto_prev(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev notifies on wrap-around', function() - local working_path = '/tmp/diffs_test_prev_wrap_notify.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to last hunk') then - notified = true - end - end - - merge.goto_prev(d_bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('skips resolved hunks', function() - local working_path = '/tmp/diffs_test_skip_resolved.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(d_bufnr, default_config()) - - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - merge.goto_next(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('hunk hints', function() - it('adds keymap hints on hunk header lines', function() - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }) - - merge.setup_keymaps(d_bufnr, default_config()) - - local extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local hint_marks = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - local text = '' - for _, chunk in ipairs(mark[4].virt_text) do - text = text .. chunk[1] - end - table.insert(hint_marks, { line = mark[2], text = text }) - end - end - assert.are.equal(1, #hint_marks) - assert.are.equal(3, hint_marks[1].line) - assert.is_truthy(hint_marks[1].text:find('doo')) - assert.is_truthy(hint_marks[1].text:find('dot')) - - helpers.delete_buffer(d_bufnr) - end) - end) - - describe('setup_keymaps', function() - it('clears resolved state on re-init', function() - local working_path = '/tmp/diffs_test_reinit.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local cfg = default_config() - merge.resolve_ours(d_bufnr, cfg) - assert.is_true(merge.is_resolved(d_bufnr, 1)) - - local extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - assert.is_true(#extmarks > 0) - - merge.setup_keymaps(d_bufnr, cfg) - - assert.is_false(merge.is_resolved(d_bufnr, 1)) - extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local resolved_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - for _, chunk in ipairs(mark[4].virt_text) do - if chunk[1]:match('resolved') then - resolved_count = resolved_count + 1 - end - end - end - end - assert.are.equal(0, resolved_count) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('fugitive integration', function() - it('parse_file_line returns status for unmerged files', function() - local fugitive = require('diffs.fugitive') - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'Unstaged (1)', - 'U conflict.lua', - }) - local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2) - assert.are.equal('conflict.lua', filename) - assert.are.equal('unstaged', section) - assert.is_false(is_header) - assert.is_nil(old_filename) - assert.are.equal('U', status) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('walkback from hunk line propagates status', function() - local fugitive = require('diffs.fugitive') - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'Unstaged (1)', - 'U conflict.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - }) - local _, _, _, _, status = fugitive.get_file_at_line(buf, 5) - assert.are.equal('U', status) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - end) -end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index d88f9b7..11ac3be 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -391,6 +391,37 @@ describe('parser', function() vim.fn.delete(repo_root, 'rf') end) + it('detects python from shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang-py' + vim.fn.mkdir(repo_root, 'p') + + local file_path = repo_root .. '/deploy' + local f = io.open(file_path, 'w') + f:write('#!/usr/bin/env python3\n') + f:write('import sys\n') + f:write('print("hi")\n') + f:close() + + local diff_buf = create_buffer({ + 'M deploy', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env python3', + '+import sys', + ' print("hi")', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('deploy', hunks[1].filename) + assert.are.equal('python', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') + end) + it('extracts file line numbers from @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -409,6 +440,22 @@ describe('parser', function() delete_buffer(bufnr) end) + it('extracts large line numbers from @@ header', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -100,20 +200,30 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(100, hunks[1].file_old_start) + assert.are.equal(20, hunks[1].file_old_count) + assert.are.equal(200, hunks[1].file_new_start) + assert.are.equal(30, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + it('defaults count to 1 when omitted in @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -425,123 +472,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('recognizes U prefix for unmerged files', function() - local bufnr = create_buffer({ - 'U merge_me.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '++=======', - '+ return 2', - '++>>>>>>> feature', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('merge_me.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('sets prefix_width 2 from @@@ combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(2, hunks[1].prefix_width) - delete_buffer(bufnr) - end) - - it('sets prefix_width 1 for standard @@ unified diff', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,3 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(1, hunks[1].prefix_width) - delete_buffer(bufnr) - end) - - it('collects all combined diff line types as hunk content', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,3 +1,5 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(5, #hunks[1].lines) - assert.are.equal(' local M = {}', hunks[1].lines[1]) - assert.are.equal('++<<<<<<< HEAD', hunks[1].lines[2]) - assert.are.equal(' + return 1', hunks[1].lines[3]) - assert.are.equal('+ local x = 2', hunks[1].lines[4]) - assert.are.equal(' end', hunks[1].lines[5]) - delete_buffer(bufnr) - end) - - it('extracts new range from combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(1, hunks[1].file_new_start) - assert.are.equal(9, hunks[1].file_new_count) - assert.is_nil(hunks[1].file_old_start) - delete_buffer(bufnr) - end) - - it('extracts header context from combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@ function M.greet()', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('function M.greet()', hunks[1].header_context) - delete_buffer(bufnr) - end) - - it('resets prefix_width when switching from combined to unified diff', function() - local bufnr = create_buffer({ - 'U merge.lua', - '@@@ -1,1 -1,1 +1,3 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - 'M other.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(2, #hunks) - assert.are.equal(2, hunks[1].prefix_width) - assert.are.equal(1, hunks[2].prefix_width) - delete_buffer(bufnr) - end) - it('stores repo_root on hunk when available', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -557,5 +487,18 @@ describe('parser', function() assert.are.equal('/tmp/test-repo', hunks[1].repo_root) delete_buffer(bufnr) end) + + it('repo_root is nil when not available', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.is_nil(hunks[1].repo_root) + delete_buffer(bufnr) + end) end) end)