neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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