Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/file_changed.lua

Raw
-- Sane handling of files that change on disk while loaded in neovim.
--
-- Neovim already has `autoread` on by default, but it only ever looks at the
-- filesystem after a shell command, after `:checktime` and on `FocusGained`
-- ( see `:help timestamp` ). So a formatter, a git checkout or an agent can
-- rewrite a file and neovim happily keeps showing stale text for minutes.
--
-- Three layers, from fastest to most reliable:
--
--   1. `uv.fs_event` on the *parent directory* of every loaded file
--      -> push notification, millisecond latency, but unreliable
--         ( network shares, WSL interop, watcher limits ).
--   2. a plain repeating timer
--      -> the floor. always works, independent of terminal focus reporting
--         and of `updatetime`.
--   3. `FocusGained` and friends
--      -> zero latency for the common "alt tab back into neovim" case.
--
-- All three funnel into `:checktime`, which decides nothing by itself. The
-- actual policy lives in the `FileChangedShell` handler below.
--
-- Note on the directory watch: tools write files by creating a temporary file
-- and renaming it over the target. That replaces the inode, so an `fs_event`
-- registered on the *file* goes deaf after the first write. Watching the
-- containing directory survives this.

local auto = require 'std.auto'
local uv = vim.uv

-- reading a file again must stay undoable, also for big files.
-- see `:help 'undoreload'`
vim.opt.undoreload = 100000
-- global value. plugins may still set a local one, that is their business.
vim.opt.autoread = true

--[[ POLICY ]]

-- `FileChangedShell` only fires for the cases neovim can not decide on its
-- own. A clean buffer whose file changed is reloaded silently by `autoread`
-- and never reaches this handler.
--
-- Defining this autocommand suppresses the built in W11/W12/W16/E211 warnings
-- and dialogs entirely, so it owns the whole user experience.

--- remembers which buffers we already complained about, to not repeat
--- ourselves on every check. keyed by buffer number.
--- deliberately not a buffer local variable: `FileChangedShell` runs with the
--- buffer list locked.
---@type table<integer, string>
local warned = {}

local relative_name = function ( path ) return vim.fn.fnamemodify( path, ':~:.' ) end

local warn_once = function ( buffer, reason, message )
  if warned[buffer] == reason then return end
  warned[buffer] = reason
  vim.notify( message, vim.log.levels.WARN, { title = 'file changed', } )
end

auto 'file_changed_policy' {
  {
    description = 'decide what to do with a file that changed outside of neovim',
    events = 'FileChangedShell',
    command = function ( event )
      local reason = vim.v.fcs_reason
      local name = relative_name( event.file )

      if reason == 'mode' or reason == 'time' then
        -- permissions or timestamp only, contents are identical.
        -- typical for version control checkouts. nothing to lose.
        vim.v.fcs_choice = 'reload'
      elseif reason == 'changed' then
        -- contents differ and the buffer is clean.
        -- 'edit' instead of 'reload' so that 'fileformat', 'fileencoding' and
        -- 'binary' are detected again. a formatter or a `dos2unix` run may
        -- have changed them.
        vim.v.fcs_choice = 'edit'
      elseif reason == 'conflict' then
        -- changed on disk AND in the buffer. never pick a winner.
        vim.v.fcs_choice = ''
        warn_once( event.buf, reason,
          ( '`%s` changed on disk **and** in the buffer.\n' ..
            '`:DiffDisk` compare · `:e!` take disk · `:w!` take buffer' ):format( name ) )
      elseif reason == 'deleted' then
        -- keep the text. it may be the only copy left.
        vim.v.fcs_choice = ''
        warn_once( event.buf, reason, ( '`%s` is gone from disk.' ):format( name ) )
      end
    end,
  },
  {
    description = 'report a file that was reloaded from disk',
    events = 'FileChangedShellPost',
    command = function ( event )
      warned[event.buf] = nil
      if vim.wo.diff then vim.cmd.diffupdate() end
      vim.notify( ( 'reloaded `%s`' ):format( relative_name( event.file ) ),
        vim.log.levels.INFO, { title = 'file changed', } )
    end,
  },
  {
    -- writing, or reading the file again ( `:e!`, `:edit` ), means buffer and
    -- disk agree again. without this the next conflict would be swallowed by
    -- the latch of the previous one.
    description = 'forget complaints once the buffer is in sync again',
    events = { 'BufWritePost', 'BufReadPost', 'BufWipeout', },
    command = function ( event ) warned[event.buf] = nil end,
  },
}

--[[ BUFFERS ]]

--- the file a buffer is backed by, or nil if it is not a plain file buffer.
---@param buffer integer
---@return string? path, string? directory, string? file
local file_of = function ( buffer )
  if not vim.api.nvim_buf_is_valid( buffer ) then return end
  if vim.bo[buffer].buftype ~= '' then return end
  -- reloading these costs more than the staleness does
  if vim.bo[buffer].filetype == 'bigfile' then return end

  local name = vim.api.nvim_buf_get_name( buffer )
  -- `oil://`, `fugitive://`, ... are not files
  if name == '' or name:match '^%a[%w+.-]*://' then return end

  local path = vim.fs.normalize( vim.fn.fnamemodify( name, ':p' ) )
  return path, vim.fs.dirname( path ), vim.fs.basename( path )
end

--[[ TRIGGERS ]]

-- Everything funnels through one debounced, quiescence gated `:checktime`.
--
-- The gate matters: a lot of tools truncate a file and only then write it,
-- and on windows the gap between the two can be hundreds of milliseconds.
-- Reading in that window loads an empty file, the cursor gets clamped to
-- line 1, and the next read restores the contents but no longer knows where
-- the cursor was. So wait until size and mtime of every watched file stopped
-- moving before looking at them.

local settle_delay = 150
local settle_attempts = 8

local timer = assert( uv.new_timer() )
---@type table<string, string>?
local previous_fingerprint = nil
local attempt = 0

--- size and modification time of every file a loaded buffer is backed by.
--- The second return value marks a file that is almost certainly half written:
--- it is empty while its buffer still holds text. Waiting for the fingerprint
--- to stop moving does not catch this, because a truncated file sits there
--- perfectly stable until the writer gets around to producing its bytes.
---@return table<string, string> fingerprints, boolean half_written
local fingerprint = function ()
  local prints = {}
  local half_written = false
  for _, buffer in ipairs( vim.api.nvim_list_bufs() ) do
    if vim.api.nvim_buf_is_loaded( buffer ) then
      local path = file_of( buffer )
      if path then
        local stat = uv.fs_stat( path )
        prints[path] = stat
            and ( '%d:%d:%d' ):format( stat.size, stat.mtime.sec, stat.mtime.nsec )
            or 'gone'
        if stat and stat.size == 0 and vim.api.nvim_buf_line_count( buffer ) > 1 then
          half_written = true
        end
      end
    end
  end
  return prints, half_written
end

local settle

local function arm ( delay )
  timer:stop()
  timer:start( delay, 0, vim.schedule_wrap( settle ) )
end

settle = function ()
  -- the reload is postponed by neovim until it is harmless anyway, but
  -- checking while typing or selecting only produces noise.
  if vim.fn.getcmdwintype() ~= '' then return end -- E11
  if vim.api.nvim_get_mode().mode ~= 'n' then return end

  local current, half_written = fingerprint()
  local moved = not vim.deep_equal( previous_fingerprint, current )
  if attempt < settle_attempts and ( moved or half_written ) then
    -- somebody is still writing. look again in a moment.
    -- after `settle_attempts` we give up and read whatever is there: the file
    -- may really have been emptied on purpose.
    previous_fingerprint = current
    attempt = attempt + 1
    arm( settle_delay )
    return
  end

  previous_fingerprint = nil
  attempt = 0
  pcall( vim.cmd.checktime )
end

local check = function ( delay )
  previous_fingerprint = nil
  attempt = 0
  arm( delay )
end

auto 'file_changed_triggers' {
  description = 'check timestamps whenever attention returns to neovim',
  events = { 'FocusGained', 'BufEnter', 'TermLeave', 'TermClose', 'VimResume', 'InsertLeave', },
  command = function () check( 50 ) end,
}

-- The floor. Deliberately not `CursorHold`: that event is edge triggered, it
-- fires once per idle period and never again until the cursor moves. Sitting
-- still and reading is exactly when an external change would be missed.
local poll_interval = 2000
local poll = assert( uv.new_timer() )
poll:start( poll_interval, poll_interval, vim.schedule_wrap( function ()
  -- do not interrupt a debounce that is already running
  if timer:is_active() then return end
  check( 0 )
end ) )

--[[ DIRECTORY WATCHERS ]]

-- one `uv_fs_event_t` per directory, shared by all buffers in it.
local max_directories = 64

---@type table<string, { handle: uv.uv_fs_event_t, files: table<string, true> }>
local watchers = {}

--- plain lua, no `vim.*`: this also runs inside libuv callbacks.
local basename = function ( path ) return path:match '[^/\\]*$' end

local watch = function ( buffer )
  local _, directory, file = file_of( buffer )
  if not directory or not file then return end

  local watcher = watchers[directory]
  if watcher then
    watcher.files[file] = true
    return
  end

  if vim.tbl_count( watchers ) >= max_directories then return end

  local handle = assert( uv.new_fs_event() )
  local _, error_message = handle:start( directory, {}, function ( error, changed )
    if error then return end
    local current = watchers[directory]
    if not current then return end
    -- `changed` is nil on some platforms. then we can not tell which file it
    -- was and have to check them all, which `:checktime` does anyway.
    if changed and not current.files[basename( changed )] then return end
    check( 100 )
  end )

  if error_message then
    -- unwatchable directory ( network share, permissions, ... ).
    -- the poll timer still covers this buffer.
    handle:close()
    return
  end

  watchers[directory] = { handle = handle, files = { [file] = true, }, }
end

local unwatch = function ( buffer )
  local _, directory, file = file_of( buffer )
  if not directory or not file then return end

  local watcher = watchers[directory]
  if not watcher then return end

  watcher.files[file] = nil
  if next( watcher.files ) == nil then
    watcher.handle:stop()
    watcher.handle:close()
    watchers[directory] = nil
  end
end

auto 'file_changed_watchers' {
  {
    description = 'watch the directory of every file backed buffer',
    events = { 'BufReadPost', 'BufNewFile', 'BufFilePost', 'BufWritePost', },
    command = function ( event ) watch( event.buf ) end,
  },
  {
    description = 'release the directory watcher when a buffer goes away',
    events = { 'BufWipeout', 'BufFilePre', },
    command = function ( event ) unwatch( event.buf ) end,
  },
}

-- buffers that were already loaded before this module ran
for _, buffer in ipairs( vim.api.nvim_list_bufs() ) do
  if vim.api.nvim_buf_is_loaded( buffer ) then watch( buffer ) end
end

--[[ COMMANDS ]]

vim.api.nvim_create_user_command( 'DiffDisk', function ()
  local filetype = vim.bo.filetype
  vim.cmd 'vertical new'
  vim.opt_local.buftype = 'nofile'
  vim.opt_local.bufhidden = 'wipe'
  vim.opt_local.swapfile = false
  vim.cmd 'read ++edit #'
  vim.cmd '0d_'
  vim.bo.filetype = filetype
  vim.cmd 'diffthis'
  vim.cmd 'wincmd p'
  vim.cmd 'diffthis'
end, { desc = 'diff the current buffer against the file on disk', } )