From fec02ae8e411658a5f97291ac9d7cf7426f1fcbf Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 12 Oct 2025 04:24:39 +0200 Subject: [PATCH] feat(plugins): nvim.difftool can compare directories #35448 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Built-in diff mode (nvim -d) does not support directory diffing as required by git difftool -d. This makes it difficult to compare entire directories, detect renames, and navigate changes efficiently. Solution: Add a DiffTool plugin and command that enables side-by-side diffing of files and directories in Neovim. The plugin supports rename detection, highlights changes in the quickfix list, and provides a user command for easy invocation. This allows proper integration with git difftool -d for directory comparison. Example git config: ```ini [diff] tool = nvim_difftool [difftool "nvim_difftool"] cmd = nvim -c "packadd nvim.difftool" -c "DiffTool $LOCAL $REMOTE" ``` Signed-off-by: Tomas Slusny Co-authored-by: Phạm Bình An <111893501+brianhuster@users.noreply.github.com> Co-authored-by: Justin M. Keyes --- runtime/doc/news.txt | 1 + runtime/doc/plugins.txt | 41 ++ runtime/lua/vim/_core/util.lua | 36 ++ .../dist/opt/nvim.difftool/lua/difftool.lua | 494 ++++++++++++++++++ .../opt/nvim.difftool/plugin/difftool.lua | 12 + src/gen/gen_vimdoc.lua | 2 + test/functional/plugin/difftool_spec.lua | 64 +++ 7 files changed, 650 insertions(+) create mode 100644 runtime/lua/vim/_core/util.lua create mode 100644 runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua create mode 100644 runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua create mode 100644 test/functional/plugin/difftool_spec.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 4a8ef2bba5..a05f22d9b3 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -203,6 +203,7 @@ EDITOR • |:Undotree| for visually navigating the |undo-tree| • |:wall| permits a |++p| option for creating parent directories when writing changed buffers. +• The |:DiffTool| command compares directories (and files). EVENTS diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt index 1fd46e9725..34eea2a076 100644 --- a/runtime/doc/plugins.txt +++ b/runtime/doc/plugins.txt @@ -18,6 +18,7 @@ loaded by default while others are not loaded until requested by |:packadd|. Standard plugins ~ *standard-plugin-list* Help-link Loaded Short description ~ +|difftool| No Compares two directories or files side-by-side |editorconfig| Yes Detect and interpret editorconfig |ft-shada| Yes Allows editing binary |shada| files |man.lua| Yes View manpages in Nvim @@ -40,6 +41,46 @@ Help-link Loaded Short description ~ |tohtml| Yes Convert buffer to html, syntax included |undotree| No Interactive textual undotree +============================================================================== +Builtin plugin: difftool *difftool* + + +:DiffTool {left} {right} *:DiffTool* +Compares two directories or files side-by-side. +Supports directory diffing, rename detection, and highlights changes +in quickfix list. + +The plugin is not loaded by default; use `:packadd nvim.difftool` before +invoking `:DiffTool`. + +Example `git difftool -d` integration using `DiffTool` command: >ini + [difftool "nvim_difftool"] + cmd = nvim -c "packadd nvim.difftool" -c "DiffTool $LOCAL $REMOTE" + [diff] + tool = nvim_difftool +< + + +open({left}, {right}, {opt}) *difftool.open()* + Diff two files or directories + + Parameters: ~ + • {left} (`string`) + • {right} (`string`) + • {opt} (`table?`) + • {rename.detect} (`boolean`, default: `false`) Whether to + detect renames + • {rename.similarity} (`number`, default: `0.5`) Minimum + similarity for rename detection (0 to 1) + • {rename.chunk_size} (`number`, default: `4096`) Maximum + chunk size to read from files for similarity calculation + • {method} (`'auto'|'builtin'|'diffr'`, default: `auto`) Diff + method to use + • {ignore} (`string[]`, default: `{}`) List of file patterns + to ignore (for example: `'.git', '*.log'`) + • {rename} (`table`) Controls rename detection + + ============================================================================== Builtin plugin: editorconfig *editorconfig* diff --git a/runtime/lua/vim/_core/util.lua b/runtime/lua/vim/_core/util.lua new file mode 100644 index 0000000000..561118369e --- /dev/null +++ b/runtime/lua/vim/_core/util.lua @@ -0,0 +1,36 @@ +local M = {} + +--- Edit a file in a specific window +--- @param winnr number +--- @param file string +--- @return number buffer number of the edited buffer +M.edit_in = function(winnr, file) + return vim.api.nvim_win_call(winnr, function() + local current = vim.fs.abspath(vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(winnr))) + + -- Check if the current buffer is already the target file + if current == (file and vim.fs.abspath(file) or '') then + return vim.api.nvim_get_current_buf() + end + + -- Read the file into the buffer + vim.cmd.edit(vim.fn.fnameescape(file)) + return vim.api.nvim_get_current_buf() + end) +end + +--- Read a chunk of data from a file +--- @param file string +--- @param size number +--- @return string? chunk or nil on error +function M.read_chunk(file, size) + local fd = io.open(file, 'rb') + if not fd then + return nil + end + local chunk = fd:read(size) + fd:close() + return tostring(chunk) +end + +return M diff --git a/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua b/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua new file mode 100644 index 0000000000..269eabcca4 --- /dev/null +++ b/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua @@ -0,0 +1,494 @@ +--- @brief +---
help
+---:DiffTool {left} {right}                                           *:DiffTool*
+---Compares two directories or files side-by-side.
+---Supports directory diffing, rename detection, and highlights changes
+---in quickfix list.
+---
+--- +--- The plugin is not loaded by default; use `:packadd nvim.difftool` before invoking `:DiffTool`. +--- +--- Example `git difftool -d` integration using `DiffTool` command: +--- +--- ```ini +--- [difftool "nvim_difftool"] +--- cmd = nvim -c "packadd nvim.difftool" -c "DiffTool $LOCAL $REMOTE" +--- [diff] +--- tool = nvim_difftool +--- ``` + +local highlight_groups = { + A = 'DiffAdd', + D = 'DiffDelete', + M = 'DiffText', + R = 'DiffChange', +} + +local layout = { + group = nil, + left_win = nil, + right_win = nil, +} + +local util = require('vim._core.util') + +--- Set up a consistent layout with two diff windows +--- @param with_qf boolean whether to open the quickfix window +local function setup_layout(with_qf) + local wins = vim.api.nvim_tabpage_list_wins(0) + local left_valid = layout.left_win and vim.api.nvim_win_is_valid(layout.left_win) + local right_valid = layout.right_win and vim.api.nvim_win_is_valid(layout.right_win) + local wins_passed = left_valid and right_valid + + local qf_passed = not with_qf + if not qf_passed and wins_passed then + for _, win in ipairs(wins) do + local buf = vim.api.nvim_win_get_buf(win) + local ft = vim.bo[buf].filetype + if ft == 'qf' then + qf_passed = true + break + end + end + end + + if wins_passed and qf_passed then + return false + end + + vim.cmd.only() + layout.left_win = vim.api.nvim_get_current_win() + vim.cmd.vsplit() + layout.right_win = vim.api.nvim_get_current_win() + + if with_qf then + vim.cmd('botright copen') + end + vim.api.nvim_set_current_win(layout.right_win) +end + +--- Diff two files +--- @param left_file string +--- @param right_file string +--- @param with_qf boolean? whether to open the quickfix window +local function diff_files(left_file, right_file, with_qf) + setup_layout(with_qf or false) + + local left_buf = util.edit_in(layout.left_win, left_file) + local right_buf = util.edit_in(layout.right_win, right_file) + + -- When one of the windows is closed, clean up the layout + vim.api.nvim_create_autocmd('WinClosed', { + group = layout.group, + buffer = left_buf, + callback = function() + if layout.group and layout.left_win then + vim.api.nvim_del_augroup_by_id(layout.group) + layout.left_win = nil + layout.group = nil + vim.fn.setqflist({}) + vim.cmd.cclose() + end + end, + }) + vim.api.nvim_create_autocmd('WinClosed', { + group = layout.group, + buffer = right_buf, + callback = function() + if layout.group and layout.right_win then + vim.api.nvim_del_augroup_by_id(layout.group) + layout.right_win = nil + layout.group = nil + vim.fn.setqflist({}) + vim.cmd.cclose() + end + end, + }) + + vim.cmd('diffoff!') + vim.api.nvim_win_call(layout.left_win, vim.cmd.diffthis) + vim.api.nvim_win_call(layout.right_win, vim.cmd.diffthis) +end + +--- Diff two directories using external `diff` command +--- @param left_dir string +--- @param right_dir string +--- @param opt difftool.opt +--- @return table[] list of quickfix entries +local function diff_dirs_diffr(left_dir, right_dir, opt) + local args = { 'diff', '-qrN' } + for _, pattern in ipairs(opt.ignore) do + table.insert(args, '-x') + table.insert(args, pattern) + end + table.insert(args, left_dir) + table.insert(args, right_dir) + + local output = vim.fn.system(args) + local lines = vim.split(output, '\n') + local qf_entries = {} + + for _, line in ipairs(lines) do + local modified_left, modified_right = line:match('^Files (.+) and (.+) differ$') + if modified_left and modified_right then + local left_exists = vim.fn.filereadable(modified_left) == 1 + local right_exists = vim.fn.filereadable(modified_right) == 1 + local status = '?' + if left_exists and right_exists then + status = 'M' + elseif left_exists then + status = 'D' + elseif right_exists then + status = 'A' + end + table.insert(qf_entries, { + filename = modified_right, + text = status, + user_data = { + diff = true, + rel = vim.fs.relpath(left_dir, modified_left), + left = vim.fs.abspath(modified_left), + right = vim.fs.abspath(modified_right), + }, + }) + end + end + + return qf_entries +end + +--- Diff two directories using built-in Lua implementation +--- @param left_dir string +--- @param right_dir string +--- @param opt difftool.opt +--- @return table[] list of quickfix entries +local function diff_dirs_builtin(left_dir, right_dir, opt) + --- @param rel_path string? + --- @param ignore string[] + --- @return boolean + local function is_ignored(rel_path, ignore) + if not rel_path then + return false + end + for _, pat in ipairs(ignore) do + if vim.fn.match(rel_path, pat) >= 0 then + return true + end + end + return false + end + + --- @param file1 string + --- @param file2 string + --- @param chunk_size number + --- @param chunk_cache table + --- @return number similarity ratio (0 to 1) + local function calculate_similarity(file1, file2, chunk_size, chunk_cache) + -- Get or read chunk for file1 + local chunk1 = chunk_cache[file1] + if not chunk1 then + chunk1 = util.read_chunk(file1, chunk_size) + chunk_cache[file1] = chunk1 + end + + -- Get or read chunk for file2 + local chunk2 = chunk_cache[file2] + if not chunk2 then + chunk2 = util.read_chunk(file2, chunk_size) + chunk_cache[file2] = chunk2 + end + + if not chunk1 or not chunk2 then + return 0 + end + if chunk1 == chunk2 then + return 1 + end + local matches = 0 + local len = math.min(#chunk1, #chunk2) + for i = 1, len do + if chunk1:sub(i, i) == chunk2:sub(i, i) then + matches = matches + 1 + end + end + return matches / len + end + + -- Create a map of all relative paths + + --- @type table + local all_paths = {} + --- @type table + local left_only = {} + --- @type table + local right_only = {} + + local function process_files_in_directory(dir_path, is_left) + local files = vim.fs.find(function(name, path) + local rel_path = vim.fs.relpath(dir_path, vim.fs.joinpath(path, name)) + return not is_ignored(rel_path, opt.ignore) + end, { limit = math.huge, path = dir_path, follow = false }) + + for _, full_path in ipairs(files) do + local rel_path = vim.fs.relpath(dir_path, full_path) + if rel_path then + full_path = vim.fn.resolve(full_path) + + if vim.fn.isdirectory(full_path) == 0 then + all_paths[rel_path] = all_paths[rel_path] or { left = nil, right = nil } + + if is_left then + all_paths[rel_path].left = full_path + if not all_paths[rel_path].right then + left_only[rel_path] = full_path + end + else + all_paths[rel_path].right = full_path + if not all_paths[rel_path].left then + right_only[rel_path] = full_path + end + end + end + end + end + end + + -- Process both directories + process_files_in_directory(left_dir, true) + process_files_in_directory(right_dir, false) + + --- @type table + local renamed = {} + --- @type table + local chunk_cache = {} + + -- Detect possible renames + if opt.rename.detect then + for left_rel, left_path in pairs(left_only) do + ---@type {similarity: number, path: string?, rel: string} + local best_match = { similarity = opt.rename.similarity, path = nil } + + for right_rel, right_path in pairs(right_only) do + local similarity = + calculate_similarity(left_path, right_path, opt.rename.chunk_size, chunk_cache) + + if similarity > best_match.similarity then + best_match = { + similarity = similarity, + path = right_path, + rel = right_rel, + } + end + end + + if best_match.path and best_match.rel then + renamed[left_rel] = best_match.rel + all_paths[left_rel].right = best_match.path + all_paths[best_match.rel] = nil + left_only[left_rel] = nil + right_only[best_match.rel] = nil + end + end + end + + local qf_entries = {} + + -- Convert to quickfix entries + for rel_path, files in pairs(all_paths) do + local status = nil + if files.left and files.right then + --- @type number + local similarity + if opt.rename.detect then + similarity = + calculate_similarity(files.left, files.right, opt.rename.chunk_size, chunk_cache) + else + similarity = vim.fn.getfsize(files.left) == vim.fn.getfsize(files.right) and 1 or 0 + end + if similarity < 1 then + status = renamed[rel_path] and 'R' or 'M' + end + elseif files.left then + status = 'D' + files.right = right_dir .. rel_path + elseif files.right then + status = 'A' + files.left = left_dir .. rel_path + end + + if status then + table.insert(qf_entries, { + filename = files.right, + text = status, + user_data = { + diff = true, + rel = rel_path, + left = files.left, + right = files.right, + }, + }) + end + end + + return qf_entries +end + +--- Diff two directories +--- @param left_dir string +--- @param right_dir string +--- @param opt difftool.opt +local function diff_dirs(left_dir, right_dir, opt) + local method = opt.method + if method == 'auto' then + if not opt.rename.detect and vim.fn.executable('diff') == 1 then + method = 'diffr' + else + method = 'builtin' + end + end + + --- @type table[] + local qf_entries + if method == 'diffr' then + qf_entries = diff_dirs_diffr(left_dir, right_dir, opt) + elseif method == 'builtin' then + qf_entries = diff_dirs_builtin(left_dir, right_dir, opt) + else + vim.notify('Unknown diff method: ' .. method, vim.log.levels.ERROR) + return + end + + -- Sort entries by filename for consistency + table.sort(qf_entries, function(a, b) + return a.user_data.rel < b.user_data.rel + end) + + vim.fn.setqflist({}, 'r', { + nr = '$', + title = 'DiffTool', + items = qf_entries, + ---@param info {id: number, start_idx: number, end_idx: number} + quickfixtextfunc = function(info) + --- @type table[] + local items = vim.fn.getqflist({ id = info.id, items = 1 }).items + local out = {} + for item = info.start_idx, info.end_idx do + local entry = items[item] + table.insert(out, entry.text .. ' ' .. entry.user_data.rel) + end + return out + end, + }) + + setup_layout(true) + vim.cmd.cfirst() +end + +local M = {} + +--- @class difftool.opt +--- @inlinedoc +--- +--- Diff method to use +--- (default: `auto`) +--- @field method 'auto'|'builtin'|'diffr' +--- +--- List of file patterns to ignore (for example: `'.git', '*.log'`) +--- (default: `{}`) +--- @field ignore string[] +--- +--- Rename detection options (supported only by `builtin` method) +--- @field rename table Controls rename detection +--- +--- - {rename.detect} (`boolean`, default: `false`) Whether to detect renames +--- - {rename.similarity} (`number`, default: `0.5`) Minimum similarity for rename detection (0 to 1) +--- - {rename.chunk_size} (`number`, default: `4096`) Maximum chunk size to read from files for similarity calculation + +--- Diff two files or directories +--- @param left string +--- @param right string +--- @param opt? difftool.opt +function M.open(left, right, opt) + if not left or not right then + vim.notify('Both arguments are required', vim.log.levels.ERROR) + return + end + + local config = vim.tbl_deep_extend('force', { + method = 'auto', + ignore = {}, + rename = { + detect = false, + similarity = 0.5, + chunk_size = 4096, + }, + }, opt or {}) + + layout.group = vim.api.nvim_create_augroup('nvim.difftool.events', { clear = true }) + local hl_id = vim.api.nvim_create_namespace('nvim.difftool.hl') + + local function get_diff_entry() + --- @type {idx: number, items: table[], size: number} + local qf_info = vim.fn.getqflist({ idx = 0, items = 1, size = 1 }) + if qf_info.size == 0 then + return false + end + + local entry = qf_info.items[qf_info.idx] + if not entry or not entry.user_data or not entry.user_data.diff then + return nil + end + + return entry + end + + vim.api.nvim_create_autocmd('BufWinEnter', { + group = layout.group, + pattern = 'quickfix', + callback = function(args) + if not get_diff_entry() then + return + end + + vim.api.nvim_buf_clear_namespace(args.buf, hl_id, 0, -1) + local lines = vim.api.nvim_buf_get_lines(args.buf, 0, -1, false) + + -- Map status codes to highlight groups + for i, line in ipairs(lines) do + local status = line:match('^(%a) ') + local hl_group = highlight_groups[status] + if hl_group then + vim.hl.range(args.buf, hl_id, hl_group, { i - 1, 0 }, { i - 1, 1 }) + end + end + end, + }) + + vim.api.nvim_create_autocmd('BufWinEnter', { + group = layout.group, + pattern = '*', + callback = function() + local entry = get_diff_entry() + if not entry then + return + end + + vim.schedule(function() + diff_files(entry.user_data.left, entry.user_data.right, true) + end) + end, + }) + + left = vim.fs.normalize(left) + right = vim.fs.normalize(right) + + if vim.fn.isdirectory(left) == 1 and vim.fn.isdirectory(right) == 1 then + diff_dirs(left, right, config) + elseif vim.fn.filereadable(left) == 1 and vim.fn.filereadable(right) == 1 then + diff_files(left, right) + else + vim.notify('Both arguments must be files or directories', vim.log.levels.ERROR) + end +end + +return M diff --git a/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua b/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua new file mode 100644 index 0000000000..cae828995a --- /dev/null +++ b/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua @@ -0,0 +1,12 @@ +if vim.g.loaded_difftool ~= nil then + return +end +vim.g.loaded_difftool = true + +vim.api.nvim_create_user_command('DiffTool', function(opts) + if #opts.fargs == 2 then + require('difftool').open(opts.fargs[1], opts.fargs[2]) + else + vim.notify('Usage: DiffTool ', vim.log.levels.ERROR) + end +end, { nargs = '*', complete = 'file' }) diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua index cadf5254f7..cf02b8af0b 100755 --- a/src/gen/gen_vimdoc.lua +++ b/src/gen/gen_vimdoc.lua @@ -413,6 +413,7 @@ local config = { plugins = { filename = 'plugins.txt', section_order = { + 'difftool.lua', 'editorconfig.lua', 'tohtml.lua', 'undotree.lua', @@ -421,6 +422,7 @@ local config = { 'runtime/lua/editorconfig.lua', 'runtime/lua/tohtml.lua', 'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua', + 'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua', }, fn_xform = function(fun) if fun.module == 'editorconfig' then diff --git a/test/functional/plugin/difftool_spec.lua b/test/functional/plugin/difftool_spec.lua new file mode 100644 index 0000000000..e6d67b22be --- /dev/null +++ b/test/functional/plugin/difftool_spec.lua @@ -0,0 +1,64 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() + +local clear = n.clear +local command = n.command +local eq = t.eq +local fn = n.fn + +local pathsep = n.get_pathsep() +local testdir_left = 'Xtest-difftool-left' +local testdir_right = 'Xtest-difftool-right' + +setup(function() + n.mkdir_p(testdir_left) + n.mkdir_p(testdir_right) + t.write_file(testdir_left .. pathsep .. 'file1.txt', 'hello') + t.write_file(testdir_left .. pathsep .. 'file2.txt', 'foo') + t.write_file(testdir_right .. pathsep .. 'file1.txt', 'hello world') -- modified + t.write_file(testdir_right .. pathsep .. 'file3.txt', 'bar') -- added +end) + +teardown(function() + n.rmdir(testdir_left) + n.rmdir(testdir_right) +end) + +describe('nvim.difftool', function() + before_each(function() + clear() + command('packadd nvim.difftool') + end) + + it('shows added, modified, and deleted files in quickfix', function() + command(('DiffTool %s %s'):format(testdir_left, testdir_right)) + local qflist = fn.getqflist() + local entries = {} + for _, item in ipairs(qflist) do + table.insert(entries, { text = item.text, rel = item.user_data and item.user_data.rel }) + end + + -- Should show: + -- file1.txt as modified (M) + -- file2.txt as deleted (D) + -- file3.txt as added (A) + eq({ + { text = 'M', rel = 'file1.txt' }, + { text = 'D', rel = 'file2.txt' }, + { text = 'A', rel = 'file3.txt' }, + }, entries) + end) + + it('has autocmds when diff window is opened', function() + command(('DiffTool %s %s'):format(testdir_left, testdir_right)) + local autocmds = fn.nvim_get_autocmds({ group = 'nvim.difftool.events' }) + assert(#autocmds > 0) + end) + + it('cleans up autocmds when diff window is closed', function() + command(('DiffTool %s %s'):format(testdir_left, testdir_right)) + command('q') + local ok = pcall(fn.nvim_get_autocmds, { group = 'nvim.difftool.events' }) + eq(false, ok) + end) +end)