--- @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('rightbelow 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 lines = vim.fn.systemlist(args) 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 local left = vim.fn.resolve(vim.fs.abspath(modified_left)) local right = vim.fn.resolve(vim.fs.abspath(modified_right)) table.insert(qf_entries, { filename = right, text = status, user_data = { diff = true, rel = vim.fs.relpath(left_dir, modified_left), left = left, right = 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(bufnr) --- @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 or (bufnr and entry.bufnr ~= bufnr) 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(args) local entry = get_diff_entry(args.buf) if not entry then return end vim.w.lazyredraw = true vim.schedule(function() diff_files(entry.user_data.left, entry.user_data.right, true) vim.w.lazyredraw = false 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