diff --git a/README.md b/README.md index 8d0d11c..583799e 100644 --- a/README.md +++ b/README.md @@ -69,4 +69,5 @@ luarocks install diffs.nvim - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) -- [@phanen](https://github.com/phanen) - diff header highlighting +- [@phanen](https://github.com/phanen) - diff header highlighting, unknown + filetype fix, shebang/modeline detection diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 0944759..b3f9b42 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -3,6 +3,18 @@ local M = {} local git = require('diffs.git') local dbg = require('diffs.log').dbg +---@param diff_lines string[] +---@param hunk_position { hunk_header: string, offset: integer } +---@return integer? +function M.find_hunk_line(diff_lines, hunk_position) + for i, line in ipairs(diff_lines) do + if line == hunk_position.hunk_header then + return i + hunk_position.offset + end + end + return nil +end + ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -98,6 +110,7 @@ end ---@field staged? boolean ---@field untracked? boolean ---@field old_filepath? string +---@field hunk_position? { hunk_header: string, offset: integer } ---@param filepath string ---@param opts? diffs.GdiffFileOpts @@ -175,6 +188,14 @@ function M.gdiff_file(filepath, opts) vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) + if opts.hunk_position then + local target_line = M.find_hunk_line(diff_lines, opts.hunk_position) + if target_line then + vim.api.nvim_win_set_cursor(0, { target_line, 0 }) + dbg('jumped to line %d for hunk', target_line) + end + end + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index bdb5214..a588a22 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -95,6 +95,42 @@ function M.get_file_at_line(bufnr, lnum) return nil, nil, false, nil end +---@class diffs.HunkPosition +---@field hunk_header string +---@field offset integer + +---@param bufnr integer +---@param lnum integer +---@return diffs.HunkPosition? +function M.get_hunk_position(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) + local current = lines[lnum] + + if not current then + return nil + end + + local prefix = current:sub(1, 1) + if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then + return nil + end + + for i = lnum - 1, 1, -1 do + local line = lines[i] + if line:match('^@@.-@@') then + return { + hunk_header = line, + offset = lnum - i, + } + end + if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then + break + end + end + + return nil +end + ---@param bufnr integer ---@return string? local function get_repo_root_from_fugitive(bufnr) @@ -142,12 +178,14 @@ function M.diff_file_under_cursor(vertical) local filepath = repo_root .. '/' .. filename local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil + local hunk_position = M.get_hunk_position(bufnr, lnum) dbg( - 'diff_file_under_cursor: %s (section: %s, old: %s)', + 'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)', filename, section or 'unknown', - old_filename or 'none' + old_filename or 'none', + hunk_position and tostring(hunk_position.offset) or 'none' ) commands.gdiff_file(filepath, { @@ -155,6 +193,7 @@ function M.diff_file_under_cursor(vertical) staged = section == 'staged', untracked = section == 'untracked', old_filepath = old_filepath, + hunk_position = hunk_position, }) end diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index add7169..daba5d5 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -1,13 +1,15 @@ require('spec.helpers') +local commands = require('diffs.commands') + describe('commands', function() describe('setup', function() it('registers Gdiff, Gvdiff, and Ghdiff commands', function() - require('diffs.commands').setup() - local commands = vim.api.nvim_get_commands({}) - assert.is_not_nil(commands.Gdiff) - assert.is_not_nil(commands.Gvdiff) - assert.is_not_nil(commands.Ghdiff) + commands.setup() + local cmds = vim.api.nvim_get_commands({}) + assert.is_not_nil(cmds.Gdiff) + assert.is_not_nil(cmds.Gvdiff) + assert.is_not_nil(cmds.Ghdiff) end) end) @@ -37,4 +39,60 @@ describe('commands', function() assert.are.equal('', diff_output) end) end) + + describe('find_hunk_line', function() + it('finds matching @@ header and returns target line', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + } + local hunk_position = { + hunk_header = '@@ -1,3 +1,4 @@', + offset = 2, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.equals(6, target_line) + end) + + it('returns nil when hunk header not found', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + } + local hunk_position = { + hunk_header = '@@ -99,3 +99,4 @@', + offset = 1, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.is_nil(target_line) + end) + + it('handles multiple hunks and finds correct one', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local x = 1', + ' ', + '@@ -10,3 +11,4 @@', + ' function M.foo()', + '+ print("hello")', + ' end', + } + local hunk_position = { + hunk_header = '@@ -10,3 +11,4 @@', + offset = 2, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.equals(10, target_line) + end) + end) end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index e79aed1..244e1b7 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -357,4 +357,124 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) end) + + describe('get_hunk_position', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('returns nil when on file header line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + }) + local pos = fugitive.get_hunk_position(buf, 2) + assert.is_nil(pos) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil when on @@ header line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local pos = fugitive.get_hunk_position(buf, 3) + assert.is_nil(pos) + 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,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) + assert.equals(2, pos.offset) + 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)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 6) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) + assert.equals(3, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns correct offset for first line after @@', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local pos = fugitive.get_hunk_position(buf, 4) + assert.is_not_nil(pos) + assert.equals(1, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles @@ header with context text', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -10,3 +10,4 @@ function M.hello()', + ' print("hi")', + '+ print("world")', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil when section header interrupts search', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + ' some orphan line', + }) + local pos = fugitive.get_hunk_position(buf, 3) + assert.is_nil(pos) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) end)