diff --git a/README.md b/README.md index 4683152..2be550b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ syntax highlighting. - 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 - 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()`) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index ece0bfc..755b6b5 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -14,6 +14,7 @@ Features: ~ - Syntax highlighting in |:Git| summary diffs and commit detail views - Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs +- |:Gdiff| command for unified diff against any git revision - Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility @@ -139,6 +140,34 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: or register treesitter parsers for custom filetypes, use |vim.filetype.add()| and |vim.treesitter.language.register()|. +============================================================================== +COMMANDS *diffs-commands* + +:Gdiff [revision] *:Gdiff* + Open a unified diff of the current file against a git revision. Displays + in a horizontal split below the current window. + + The diff buffer shows `+`/`-` lines with full syntax highlighting for the + code language, plus diff header highlighting for `diff --git`, `---`, + `+++`, and `@@` lines. + + Parameters: ~ + {revision} (string, optional) Git revision to diff against. + Defaults to HEAD. + + Examples: >vim + :Gdiff " diff against HEAD + :Gdiff main " diff against main branch + :Gdiff HEAD~3 " diff against 3 commits ago + :Gdiff abc123 " diff against specific commit +< + +:Gvdiff [revision] *:Gvdiff* + Like |:Gdiff| but opens in a vertical split. + +:Ghdiff [revision] *:Ghdiff* + Like |:Gdiff| but explicitly opens in a horizontal split. + ============================================================================== API *diffs-api* diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua new file mode 100644 index 0000000..6dbc176 --- /dev/null +++ b/lua/diffs/commands.lua @@ -0,0 +1,114 @@ +local M = {} + +local git = require('diffs.git') +local dbg = require('diffs.log').dbg + +---@param old_lines string[] +---@param new_lines string[] +---@param old_name string +---@param new_name string +---@return string[] +local function generate_unified_diff(old_lines, new_lines, old_name, new_name) + local old_content = table.concat(old_lines, '\n') + local new_content = table.concat(new_lines, '\n') + + local diff_fn = vim.text and vim.text.diff or vim.diff + local diff_output = diff_fn(old_content, new_content, { + result_type = 'unified', + ctxlen = 3, + }) + + if not diff_output or diff_output == '' then + return {} + end + + local diff_lines = vim.split(diff_output, '\n', { plain = true }) + + local result = { + 'diff --git a/' .. old_name .. ' b/' .. new_name, + '--- a/' .. old_name, + '+++ b/' .. new_name, + } + for _, line in ipairs(diff_lines) do + table.insert(result, line) + end + + return result +end + +---@param revision? string +---@param vertical? boolean +function M.gdiff(revision, vertical) + revision = revision or 'HEAD' + + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.api.nvim_buf_get_name(bufnr) + + if filepath == '' then + vim.notify('[diffs.nvim]: cannot diff unnamed buffer', vim.log.levels.ERROR) + return + end + + local rel_path = git.get_relative_path(filepath) + if not rel_path then + vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) + return + end + + local old_lines, err = git.get_file_content(revision, filepath) + if not old_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'unknown error'), vim.log.levels.ERROR) + return + end + + local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + + if #diff_lines == 0 then + vim.notify('[diffs.nvim]: no diff against ' .. revision, vim.log.levels.INFO) + return + end + + local diff_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) + vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path) + + vim.cmd(vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + + dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision) + + vim.schedule(function() + require('diffs').attach(diff_buf) + end) +end + +function M.setup() + vim.api.nvim_create_user_command('Gdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, false) + end, { + nargs = '?', + desc = 'Show unified diff against git revision (default: HEAD)', + }) + + vim.api.nvim_create_user_command('Gvdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, true) + end, { + nargs = '?', + desc = 'Show unified diff against git revision in vertical split', + }) + + vim.api.nvim_create_user_command('Ghdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, false) + end, { + nargs = '?', + desc = 'Show unified diff against git revision in horizontal split', + }) +end + +return M diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua new file mode 100644 index 0000000..c7632a2 --- /dev/null +++ b/lua/diffs/git.lua @@ -0,0 +1,48 @@ +local M = {} + +---@param filepath string +---@return string? +function M.get_repo_root(filepath) + local dir = vim.fn.fnamemodify(filepath, ':h') + local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) + if vim.v.shell_error ~= 0 then + return nil + end + return result[1] +end + +---@param revision string +---@param filepath string +---@return string[]?, string? +function M.get_file_content(revision, filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return nil, 'not in a git repository' + end + + local rel_path = vim.fn.fnamemodify(filepath, ':.') + if vim.startswith(filepath, repo_root) then + rel_path = filepath:sub(#repo_root + 2) + end + + local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', revision .. ':' .. rel_path }) + if vim.v.shell_error ~= 0 then + return nil, 'failed to get file at revision: ' .. revision + end + return result, nil +end + +---@param filepath string +---@return string? +function M.get_relative_path(filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return nil + end + if vim.startswith(filepath, repo_root) then + return filepath:sub(#repo_root + 2) + end + return vim.fn.fnamemodify(filepath, ':.') +end + +return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index e814dc0..9f4d882 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -28,8 +28,8 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local extmark_count = 0 local header_line = hunk.start_line - 1 - for id, node, _ in query:iter_captures(trees[1]:root(), text) do - local capture_name = '@' .. query.captures[id] + for id, node, metadata in query:iter_captures(trees[1]:root(), text) do + local capture_name = '@' .. query.captures[id] .. '.' .. lang local sr, sc, er, ec = node:range() local buf_sr = header_line + sr @@ -37,11 +37,13 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local buf_sc = col_offset + sc local buf_ec = col_offset + ec + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, end_col = buf_ec, hl_group = capture_name, - priority = 200, + priority = priority, }) extmark_count = extmark_count + 1 end @@ -105,8 +107,8 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) col_offset = col_offset or 1 local extmark_count = 0 - for id, node, _ in query:iter_captures(trees[1]:root(), code) do - local capture_name = '@' .. query.captures[id] + for id, node, metadata in query:iter_captures(trees[1]:root(), code) do + local capture_name = '@' .. query.captures[id] .. '.' .. lang local sr, sc, er, ec = node:range() local buf_sr = hunk.start_line + sr @@ -114,11 +116,13 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) local buf_sc = sc + col_offset local buf_ec = ec + col_offset + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, end_col = buf_ec, hl_group = capture_name, - priority = 200, + priority = priority, }) extmark_count = extmark_count + 1 end diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index b9f1f3b..08abcc6 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -13,7 +13,7 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG) + vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) end return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index af2bddb..8a11067 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -3,6 +3,8 @@ if vim.g.loaded_diffs then end vim.g.loaded_diffs = 1 +require('diffs.commands').setup() + vim.api.nvim_create_autocmd('FileType', { pattern = { 'fugitive', 'git' }, callback = function(args) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua new file mode 100644 index 0000000..add7169 --- /dev/null +++ b/spec/commands_spec.lua @@ -0,0 +1,40 @@ +require('spec.helpers') + +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) + end) + end) + + describe('unified diff generation', function() + local old_lines = { 'local M = {}', 'return M' } + local new_lines = { 'local M = {}', 'local x = 1', 'return M' } + local diff_fn = vim.text and vim.text.diff or vim.diff + + it('generates valid unified diff', function() + local old_content = table.concat(old_lines, '\n') + local new_content = table.concat(new_lines, '\n') + local diff_output = diff_fn(old_content, new_content, { + result_type = 'unified', + ctxlen = 3, + }) + assert.is_not_nil(diff_output) + assert.is_true(diff_output:find('@@ ') ~= nil) + assert.is_true(diff_output:find('+local x = 1') ~= nil) + end) + + it('returns empty for identical content', function() + local content = table.concat(old_lines, '\n') + local diff_output = diff_fn(content, content, { + result_type = 'unified', + ctxlen = 3, + }) + assert.are.equal('', diff_output) + end) + end) +end) diff --git a/spec/git_spec.lua b/spec/git_spec.lua new file mode 100644 index 0000000..45fd730 --- /dev/null +++ b/spec/git_spec.lua @@ -0,0 +1,54 @@ +require('spec.helpers') +local git = require('diffs.git') + +describe('git', function() + describe('get_repo_root', function() + it('returns repo root for current repo', function() + local cwd = vim.fn.getcwd() + local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua') + assert.is_not_nil(root) + assert.are.equal(cwd, root) + end) + + it('returns nil for non-git directory', function() + local root = git.get_repo_root('/tmp') + assert.is_nil(root) + end) + end) + + describe('get_file_content', function() + it('returns file content at HEAD', function() + local cwd = vim.fn.getcwd() + local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua') + assert.is_nil(err) + assert.is_not_nil(content) + assert.is_true(#content > 0) + end) + + it('returns error for non-existent file', function() + local cwd = vim.fn.getcwd() + local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua') + assert.is_nil(content) + assert.is_not_nil(err) + end) + + it('returns error for non-git directory', function() + local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt') + assert.is_nil(content) + assert.is_not_nil(err) + end) + end) + + describe('get_relative_path', function() + it('returns relative path within repo', function() + local cwd = vim.fn.getcwd() + local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua') + assert.are.equal('lua/diffs/init.lua', rel) + end) + + it('returns nil for non-git directory', function() + local rel = git.get_relative_path('/tmp/some_file.txt') + assert.is_nil(rel) + end) + end) +end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index d73028b..e870177 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -885,6 +885,111 @@ describe('highlight', function() end) end) + describe('extmark priority', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_test_priority') + end) + + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts() + return { + hide_prefix = false, + highlights = { + background = false, + gutter = false, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + }, + } + end + + it('uses priority 200 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_200 = 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 == 200 then + has_priority_200 = true + break + end + end + end + assert.is_true(has_priority_200) + delete_buffer(bufnr) + end) + + it('uses treesitter priority for diff language', function() + local bufnr = create_buffer({ + 'diff --git a/test.lua b/test.lua', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 5, + lines = { ' local x = 1', '+local y = 2' }, + header_start_line = 1, + header_lines = { + 'diff --git a/test.lua b/test.lua', + '--- a/test.lua', + '+++ b/test.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local diff_extmark_priorities = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.diff$') then + table.insert(diff_extmark_priorities, mark[4].priority) + end + end + assert.is_true(#diff_extmark_priorities > 0) + for _, priority in ipairs(diff_extmark_priorities) do + assert.is_true(priority < 200) + end + delete_buffer(bufnr) + end) + end) + describe('coalesce_syntax_spans', function() it('coalesces adjacent chars with same hl group', function() local function query_fn(_line, _col)