difftool.lua (13985B)
1 --- @brief 2 ---<pre>help 3 ---:DiffTool {left} {right} *:DiffTool* 4 ---Compares two directories or files side-by-side. 5 ---Supports directory diffing, rename detection, and highlights changes 6 ---in quickfix list. Replaces the built-in `nvim -d` diff mode with this interface. 7 ---</pre> 8 --- 9 --- The plugin is not loaded by default; use `:packadd nvim.difftool` before invoking `:DiffTool`. 10 --- 11 --- Example `git difftool -d` integration using `nvim -d` replacement: 12 --- 13 --- ```ini 14 --- [difftool "nvim_difftool"] 15 --- cmd = nvim -c \"packadd nvim.difftool\" -d \"$LOCAL\" \"$REMOTE\" 16 --- [diff] 17 --- tool = nvim_difftool 18 --- ``` 19 20 local highlight_groups = { 21 A = 'DiffAdd', 22 D = 'DiffDelete', 23 M = 'DiffText', 24 R = 'DiffChange', 25 } 26 27 local layout = { 28 group = nil, 29 left_win = nil, 30 right_win = nil, 31 } 32 33 local util = require('vim._core.util') 34 35 --- Clean up the layout state, autocmds and quickfix list 36 --- @param with_qf boolean whether the layout included a quickfix window 37 local function cleanup_layout(with_qf) 38 if layout.group then 39 vim.api.nvim_del_augroup_by_id(layout.group) 40 layout.group = nil 41 end 42 layout.left_win = nil 43 layout.right_win = nil 44 45 if with_qf then 46 vim.fn.setqflist({}) 47 vim.cmd.cclose() 48 end 49 end 50 51 --- Set up a consistent layout with two diff windows 52 --- @param with_qf boolean whether to open the quickfix window 53 local function setup_layout(with_qf) 54 local left_valid = layout.left_win and vim.api.nvim_win_is_valid(layout.left_win) 55 local right_valid = layout.right_win and vim.api.nvim_win_is_valid(layout.right_win) 56 57 if left_valid and right_valid then 58 return false 59 end 60 61 vim.cmd.only({ mods = { silent = true } }) 62 layout.left_win = vim.api.nvim_get_current_win() 63 vim.cmd('rightbelow vsplit') 64 layout.right_win = vim.api.nvim_get_current_win() 65 66 if with_qf then 67 vim.cmd('botright copen') 68 end 69 vim.api.nvim_set_current_win(layout.right_win) 70 71 -- When one of the windows is closed, clean up the layout 72 vim.api.nvim_create_autocmd('WinClosed', { 73 group = layout.group, 74 pattern = tostring(layout.left_win), 75 callback = function() 76 cleanup_layout(with_qf) 77 end, 78 }) 79 vim.api.nvim_create_autocmd('WinClosed', { 80 group = layout.group, 81 pattern = tostring(layout.right_win), 82 callback = function() 83 cleanup_layout(with_qf) 84 end, 85 }) 86 end 87 88 --- Diff two files 89 --- @param left_file string 90 --- @param right_file string 91 --- @param with_qf boolean? whether to open the quickfix window 92 local function diff_files(left_file, right_file, with_qf) 93 setup_layout(with_qf or false) 94 95 util.edit_in(layout.left_win, left_file) 96 util.edit_in(layout.right_win, right_file) 97 98 vim.cmd('diffoff!') 99 vim.api.nvim_win_call(layout.left_win, vim.cmd.diffthis) 100 vim.api.nvim_win_call(layout.right_win, vim.cmd.diffthis) 101 end 102 103 --- Diff two directories using external `diff` command 104 --- @param left_dir string 105 --- @param right_dir string 106 --- @param opt difftool.opt 107 --- @return table[] list of quickfix entries 108 local function diff_dirs_diffr(left_dir, right_dir, opt) 109 local args = { 'diff', '-qrN' } 110 for _, pattern in ipairs(opt.ignore) do 111 table.insert(args, '-x') 112 table.insert(args, pattern) 113 end 114 table.insert(args, left_dir) 115 table.insert(args, right_dir) 116 117 local lines = vim.fn.systemlist(args) 118 local qf_entries = {} 119 120 for _, line in ipairs(lines) do 121 local modified_left, modified_right = line:match('^Files (.+) and (.+) differ$') 122 if modified_left and modified_right then 123 local left_exists = vim.fn.filereadable(modified_left) == 1 124 local right_exists = vim.fn.filereadable(modified_right) == 1 125 local status = '?' 126 if left_exists and right_exists then 127 status = 'M' 128 elseif left_exists then 129 status = 'D' 130 elseif right_exists then 131 status = 'A' 132 end 133 local left = vim.fn.resolve(vim.fs.abspath(modified_left)) 134 local right = vim.fn.resolve(vim.fs.abspath(modified_right)) 135 table.insert(qf_entries, { 136 filename = right, 137 text = status, 138 user_data = { 139 diff = true, 140 rel = vim.fs.relpath(left_dir, modified_left), 141 left = left, 142 right = right, 143 }, 144 }) 145 end 146 end 147 148 return qf_entries 149 end 150 151 --- Diff two directories using built-in Lua implementation 152 --- @param left_dir string 153 --- @param right_dir string 154 --- @param opt difftool.opt 155 --- @return table[] list of quickfix entries 156 local function diff_dirs_builtin(left_dir, right_dir, opt) 157 --- @param rel_path string? 158 --- @param ignore string[] 159 --- @return boolean 160 local function is_ignored(rel_path, ignore) 161 if not rel_path then 162 return false 163 end 164 for _, pat in ipairs(ignore) do 165 if vim.fn.match(rel_path, pat) >= 0 then 166 return true 167 end 168 end 169 return false 170 end 171 172 --- @param file1 string 173 --- @param file2 string 174 --- @param chunk_size number 175 --- @param chunk_cache table<string, any> 176 --- @return number similarity ratio (0 to 1) 177 local function calculate_similarity(file1, file2, chunk_size, chunk_cache) 178 -- Get or read chunk for file1 179 local chunk1 = chunk_cache[file1] 180 if not chunk1 then 181 chunk1 = util.read_chunk(file1, chunk_size) 182 chunk_cache[file1] = chunk1 183 end 184 185 -- Get or read chunk for file2 186 local chunk2 = chunk_cache[file2] 187 if not chunk2 then 188 chunk2 = util.read_chunk(file2, chunk_size) 189 chunk_cache[file2] = chunk2 190 end 191 192 if not chunk1 or not chunk2 then 193 return 0 194 end 195 if chunk1 == chunk2 then 196 return 1 197 end 198 local matches = 0 199 local len = math.min(#chunk1, #chunk2) 200 for i = 1, len do 201 if chunk1:sub(i, i) == chunk2:sub(i, i) then 202 matches = matches + 1 203 end 204 end 205 return matches / len 206 end 207 208 -- Create a map of all relative paths 209 210 --- @type table<string, {left: string?, right: string?}> 211 local all_paths = {} 212 --- @type table<string, string> 213 local left_only = {} 214 --- @type table<string, string> 215 local right_only = {} 216 217 local function process_files_in_directory(dir_path, is_left) 218 local files = vim.fs.find(function(name, path) 219 local rel_path = vim.fs.relpath(dir_path, vim.fs.joinpath(path, name)) 220 return not is_ignored(rel_path, opt.ignore) 221 end, { limit = math.huge, path = dir_path, follow = false }) 222 223 for _, full_path in ipairs(files) do 224 local rel_path = vim.fs.relpath(dir_path, full_path) 225 if rel_path then 226 full_path = vim.fn.resolve(full_path) 227 228 if vim.fn.isdirectory(full_path) == 0 then 229 all_paths[rel_path] = all_paths[rel_path] or { left = nil, right = nil } 230 231 if is_left then 232 all_paths[rel_path].left = full_path 233 if not all_paths[rel_path].right then 234 left_only[rel_path] = full_path 235 end 236 else 237 all_paths[rel_path].right = full_path 238 if not all_paths[rel_path].left then 239 right_only[rel_path] = full_path 240 end 241 end 242 end 243 end 244 end 245 end 246 247 -- Process both directories 248 process_files_in_directory(left_dir, true) 249 process_files_in_directory(right_dir, false) 250 251 --- @type table<string, string> 252 local renamed = {} 253 --- @type table<string, string> 254 local chunk_cache = {} 255 256 -- Detect possible renames 257 if opt.rename.detect then 258 for left_rel, left_path in pairs(left_only) do 259 ---@type {similarity: number, path: string?, rel: string} 260 local best_match = { similarity = opt.rename.similarity, path = nil } 261 262 for right_rel, right_path in pairs(right_only) do 263 local similarity = 264 calculate_similarity(left_path, right_path, opt.rename.chunk_size, chunk_cache) 265 266 if similarity > best_match.similarity then 267 best_match = { 268 similarity = similarity, 269 path = right_path, 270 rel = right_rel, 271 } 272 end 273 end 274 275 if best_match.path and best_match.rel then 276 renamed[left_rel] = best_match.rel 277 all_paths[left_rel].right = best_match.path 278 all_paths[best_match.rel] = nil 279 left_only[left_rel] = nil 280 right_only[best_match.rel] = nil 281 end 282 end 283 end 284 285 local qf_entries = {} 286 287 -- Convert to quickfix entries 288 for rel_path, files in pairs(all_paths) do 289 local status = nil 290 if files.left and files.right then 291 --- @type number 292 local similarity 293 if opt.rename.detect then 294 similarity = 295 calculate_similarity(files.left, files.right, opt.rename.chunk_size, chunk_cache) 296 else 297 similarity = vim.fn.getfsize(files.left) == vim.fn.getfsize(files.right) and 1 or 0 298 end 299 if similarity < 1 then 300 status = renamed[rel_path] and 'R' or 'M' 301 end 302 elseif files.left then 303 status = 'D' 304 files.right = right_dir .. rel_path 305 elseif files.right then 306 status = 'A' 307 files.left = left_dir .. rel_path 308 end 309 310 if status then 311 table.insert(qf_entries, { 312 filename = files.right, 313 text = status, 314 user_data = { 315 diff = true, 316 rel = rel_path, 317 left = files.left, 318 right = files.right, 319 }, 320 }) 321 end 322 end 323 324 return qf_entries 325 end 326 327 --- Diff two directories 328 --- @param left_dir string 329 --- @param right_dir string 330 --- @param opt difftool.opt 331 local function diff_dirs(left_dir, right_dir, opt) 332 local method = opt.method 333 if method == 'auto' then 334 if not opt.rename.detect and vim.fn.executable('diff') == 1 then 335 method = 'diffr' 336 else 337 method = 'builtin' 338 end 339 end 340 341 --- @type table[] 342 local qf_entries 343 if method == 'diffr' then 344 qf_entries = diff_dirs_diffr(left_dir, right_dir, opt) 345 elseif method == 'builtin' then 346 qf_entries = diff_dirs_builtin(left_dir, right_dir, opt) 347 else 348 vim.notify('Unknown diff method: ' .. method, vim.log.levels.ERROR) 349 return 350 end 351 352 -- Early exit if no differences found 353 if #qf_entries == 0 then 354 vim.notify('No differences found', vim.log.levels.INFO) 355 return 356 end 357 358 -- Sort entries by filename for consistency 359 table.sort(qf_entries, function(a, b) 360 return a.user_data.rel < b.user_data.rel 361 end) 362 363 vim.fn.setqflist({}, 'r', { 364 nr = '$', 365 title = 'DiffTool', 366 items = qf_entries, 367 ---@param info {id: number, start_idx: number, end_idx: number} 368 quickfixtextfunc = function(info) 369 --- @type table[] 370 local items = vim.fn.getqflist({ id = info.id, items = 1 }).items 371 local out = {} 372 for item = info.start_idx, info.end_idx do 373 local entry = items[item] 374 table.insert(out, entry.text .. ' ' .. entry.user_data.rel) 375 end 376 return out 377 end, 378 }) 379 380 setup_layout(true) 381 vim.cmd.cfirst() 382 end 383 384 local M = {} 385 386 --- @class difftool.opt 387 --- @inlinedoc 388 --- 389 --- Diff method to use 390 --- (default: `auto`) 391 --- @field method 'auto'|'builtin'|'diffr' 392 --- 393 --- List of file patterns to ignore (for example: `'.git', '*.log'`) 394 --- (default: `{}`) 395 --- @field ignore string[] 396 --- 397 --- Rename detection options (supported only by `builtin` method) 398 --- @field rename table Controls rename detection 399 --- 400 --- - {rename.detect} (`boolean`, default: `false`) Whether to detect renames 401 --- - {rename.similarity} (`number`, default: `0.5`) Minimum similarity for rename detection (0 to 1) 402 --- - {rename.chunk_size} (`number`, default: `4096`) Maximum chunk size to read from files for similarity calculation 403 404 --- Diff two files or directories 405 --- @param left string 406 --- @param right string 407 --- @param opt? difftool.opt 408 function M.open(left, right, opt) 409 if not left or not right then 410 vim.notify('Both arguments are required', vim.log.levels.ERROR) 411 return 412 end 413 414 local config = vim.tbl_deep_extend('force', { 415 method = 'auto', 416 ignore = {}, 417 rename = { 418 detect = false, 419 similarity = 0.5, 420 chunk_size = 4096, 421 }, 422 }, opt or {}) 423 424 layout.group = vim.api.nvim_create_augroup('nvim.difftool.events', { clear = true }) 425 local hl_id = vim.api.nvim_create_namespace('nvim.difftool.hl') 426 427 local function get_diff_entry(bufnr) 428 --- @type {idx: number, items: table[], size: number} 429 local qf_info = vim.fn.getqflist({ idx = 0, items = 1, size = 1 }) 430 if qf_info.size == 0 then 431 return false 432 end 433 434 local entry = qf_info.items[qf_info.idx] 435 if 436 not entry 437 or not entry.user_data 438 or not entry.user_data.diff 439 or (bufnr and entry.bufnr ~= bufnr) 440 then 441 return nil 442 end 443 444 return entry 445 end 446 447 vim.api.nvim_create_autocmd('BufWinEnter', { 448 group = layout.group, 449 pattern = 'quickfix', 450 callback = function(args) 451 if not get_diff_entry() then 452 return 453 end 454 455 vim.api.nvim_buf_clear_namespace(args.buf, hl_id, 0, -1) 456 local lines = vim.api.nvim_buf_get_lines(args.buf, 0, -1, false) 457 458 -- Map status codes to highlight groups 459 for i, line in ipairs(lines) do 460 local status = line:match('^(%a) ') 461 local hl_group = highlight_groups[status] 462 if hl_group then 463 vim.hl.range(args.buf, hl_id, hl_group, { i - 1, 0 }, { i - 1, 1 }) 464 end 465 end 466 end, 467 }) 468 469 vim.api.nvim_create_autocmd('BufWinEnter', { 470 group = layout.group, 471 pattern = '*', 472 callback = function(args) 473 local entry = get_diff_entry(args.buf) 474 if not entry then 475 return 476 end 477 478 vim.w.lazyredraw = true 479 vim.schedule(function() 480 diff_files(entry.user_data.left, entry.user_data.right) 481 vim.w.lazyredraw = false 482 end) 483 end, 484 }) 485 486 left = vim.fs.normalize(left) 487 right = vim.fs.normalize(right) 488 489 if vim.fn.isdirectory(left) == 1 and vim.fn.isdirectory(right) == 1 then 490 diff_dirs(left, right, config) 491 elseif vim.fn.filereadable(left) == 1 and vim.fn.filereadable(right) == 1 then 492 diff_files(left, right) 493 else 494 vim.notify('Both arguments must be files or directories', vim.log.levels.ERROR) 495 end 496 end 497 498 return M