Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/tests/drive/file_changed.lua

Raw
---@diagnostic disable: need-check-nil, undefined-field, param-type-mismatch, assign-type-mismatch
-- Drives a live Neovim over RPC and asserts how it reacts to files that
-- change on disk while they are loaded.
-- Run with: just drive-file-changed
--
-- This can not be a plenary spec: the whole feature is about timers, libuv
-- filesystem events and neovim postponing reloads to a safe moment. It only
-- means anything in a real, running neovim that is mutated from the outside
-- by a real, separate process.

local ADDRESS = '127.0.0.1:' .. tostring( 6400 + math.random( 1, 180 ) )

local server = vim.system( {
  vim.v.progpath,
  '--headless',
  '--noplugin',
  '-u', 'tests/minimal_init.lua',
  '--listen', ADDRESS,
}, { stdout = false, stderr = false, } )

local channel
local connected = vim.wait( 20000, function ()
  local ok, value = pcall( vim.fn.sockconnect, 'tcp', ADDRESS, { rpc = true, } )
  if ok and value ~= 0 then
    channel = value
    return true
  end
  return false
end, 100 )

if not connected then
  io.stderr:write( 'cannot reach the driven neovim at ' .. ADDRESS .. '\n' )
  server:kill 'sigterm'
  os.exit( 1 )
end

local remote = function ( code, arguments )
  return vim.rpcrequest( channel, 'nvim_exec_lua', code, arguments or {} )
end

local failures = {}

local scenario = function ( name, body )
  local ok, failure = pcall( body )
  if ok then
    print( 'ok   ' .. name )
  else
    failures[#failures + 1] = name
    print( 'FAIL ' .. name .. ': ' .. tostring( failure ) )
  end
end

local expect = function ( condition, message )
  if not condition then error( message or 'expectation failed', 2 ) end
end

--- polls the driven neovim until `code` returns a truthy value
local until_remote = function ( code, timeout )
  return vim.wait( timeout or 8000, function ()
    local answer = remote( code )
    return answer ~= nil and answer ~= false and answer ~= vim.NIL
  end, 100 )
end

--[[ FIXTURE ]]

local directory = vim.fn.tempname()
vim.fn.mkdir( directory, 'p' )
local target = directory .. '/watched.txt'

--- overwrites the file from this process, the way any other tool would
local write_file = function ( first, last )
  local handle = assert( io.open( target, 'w' ) )
  for number = first, last do handle:write( number .. '\n' ) end
  handle:close()
end

local read_file = function ()
  local handle = assert( io.open( target, 'r' ) )
  local content = handle:read 'a'
  handle:close()
  return content
end

write_file( 1, 20 )

remote( [[
  local path = ...
  require 'bugabinga.file_changed'

  _G.DRIVE = { reloads = 0, notifications = {}, }
  vim.api.nvim_create_autocmd( 'FileChangedShellPost', {
    callback = function() _G.DRIVE.reloads = _G.DRIVE.reloads + 1 end,
  } )
  local original = vim.notify
  vim.notify = function( message, ... )
    table.insert( _G.DRIVE.notifications, message )
    return original( message, ... )
  end

  vim.cmd.edit( vim.fn.fnameescape( path ) )
  vim.api.nvim_win_set_cursor( 0, { 15, 0 } )
  return true
]], { target, } )

--- every scenario starts from the same place, so that one failure does not
--- drag the next one down with it
local reset = function ()
  remote [[
    _G.DRIVE.reloads = 0
    _G.DRIVE.notifications = {}
    pcall( vim.api.nvim_win_set_cursor, 0, { 15, 0 } )
    return true
  ]]
end

local state = function ()
  return remote [[
    return {
      reloads = _G.DRIVE.reloads,
      notifications = _G.DRIVE.notifications,
      line = vim.api.nvim_win_get_cursor( 0 )[1],
      text = vim.fn.getline '.',
      lines = vim.api.nvim_buf_line_count( 0 ),
      modified = vim.bo.modified,
    }
  ]]
end

--[[ SCENARIOS ]]

scenario( 'a changed file is reloaded without touching the cursor', function ()
  reset()
  write_file( 101, 120 )

  local reloaded = until_remote [[ return _G.DRIVE.reloads > 0 ]]
  expect( reloaded, 'the change was never noticed' )

  local now = state()
  expect( now.text == '115', 'expected line 115, got ' .. tostring( now.text ) )
  expect( now.line == 15, 'the cursor moved to line ' .. tostring( now.line ) )
end )

scenario( 'a file that is truncated first and written later is read only once it settled', function ()
  reset()
  -- the shell redirect plus slow writer case: truncate now, content later.
  local handle = assert( io.open( target, 'w' ) )
  handle:close()
  vim.uv.sleep( 400 )
  write_file( 201, 220 )

  local reloaded = until_remote [[ return _G.DRIVE.reloads > 0 ]]
  expect( reloaded, 'the change was never noticed' )
  -- give a mistaken second reload the chance to show up
  vim.uv.sleep( 3000 )

  local now = state()
  expect( now.reloads == 1, 'expected exactly one reload, got ' .. tostring( now.reloads ) )
  expect( now.lines == 20, 'the buffer holds ' .. tostring( now.lines ) .. ' lines, expected 20' )
  expect( now.text == '215', 'expected line 215, got ' .. tostring( now.text ) )
  expect( now.line == 15, 'the cursor moved to line ' .. tostring( now.line ) )
end )

scenario( 'a file replaced by rename is still followed', function ()
  reset()
  local temporary = target .. '.tmp'
  local handle = assert( io.open( temporary, 'w' ) )
  for number = 301, 320 do handle:write( number .. '\n' ) end
  handle:close()
  assert( os.remove( target ) )
  assert( os.rename( temporary, target ) )

  local reloaded = until_remote [[ return _G.DRIVE.reloads > 0 ]]
  expect( reloaded, 'the watcher did not survive the inode swap' )

  local now = state()
  expect( now.text == '315', 'expected line 315, got ' .. tostring( now.text ) )
  expect( now.line == 15, 'the cursor moved to line ' .. tostring( now.line ) )
end )

scenario( 'an unchanged file is never reloaded', function ()
  reset()
  vim.uv.sleep( 5000 ) -- several rounds of the fallback poll

  local now = state()
  expect( now.reloads == 0, 'reloaded ' .. tostring( now.reloads ) .. ' times without a change' )
  expect( #now.notifications == 0, 'notified without a change' )
end )

scenario( 'local edits are never overwritten by a change on disk', function ()
  reset()
  remote [[
    vim.api.nvim_buf_set_lines( 0, 14, 15, false, { 'MY UNSAVED EDIT' } )
    return true
  ]]
  write_file( 401, 420 )
  vim.uv.sleep( 4000 )

  local now = state()
  expect( now.reloads == 0, 'the buffer was reloaded over unsaved changes' )
  expect( now.text == 'MY UNSAVED EDIT', 'lost the local edit: ' .. tostring( now.text ) )
  expect( now.modified, 'the buffer is no longer marked modified' )
  expect( #now.notifications == 1,
    'expected exactly one warning, got ' .. tostring( #now.notifications ) )
  expect( now.notifications[1]:find( 'DiffDisk', 1, true ) ~= nil,
    'the warning does not offer a way out: ' .. tostring( now.notifications[1] ) )
end )

scenario( 'a conflict is reported again after taking the version from disk', function ()
  reset()
  remote [[ vim.cmd 'edit!' ; return true ]]
  remote [[
    vim.api.nvim_buf_set_lines( 0, 14, 15, false, { 'ANOTHER UNSAVED EDIT' } )
    return true
  ]]
  write_file( 451, 470 )
  vim.uv.sleep( 4000 )

  local now = state()
  expect( now.reloads == 0, 'the buffer was reloaded over unsaved changes' )
  expect( #now.notifications == 1,
    'the warning was swallowed by the previous one, got ' .. tostring( #now.notifications ) )
end )

scenario( 'the buffer survives the file being deleted', function ()
  reset()
  assert( os.remove( target ) )
  vim.uv.sleep( 4000 )

  local now = state()
  expect( now.text == 'ANOTHER UNSAVED EDIT', 'the text was lost with the file' )
  expect( #now.notifications == 1,
    'expected exactly one warning, got ' .. tostring( #now.notifications ) )
  expect( now.notifications[1]:find( 'gone from disk', 1, true ) ~= nil,
    'unexpected warning: ' .. tostring( now.notifications[1] ) )
end )

scenario( 'writing the buffer resolves the conflict for good', function ()
  reset()
  remote [[ vim.cmd.write() ; return true ]]
  vim.uv.sleep( 4000 )

  local now = state()
  expect( not now.modified, 'the buffer is still modified after writing' )
  expect( now.reloads == 0, 'writing caused a reload' )
  expect( #now.notifications == 0, 'writing caused a warning' )
  expect( read_file():find( 'ANOTHER UNSAVED EDIT', 1, true ) ~= nil, 'the file on disk is wrong' )
end )

scenario( 'DiffDisk shows the buffer against the file on disk', function ()
  reset()
  write_file( 501, 520 )
  expect( until_remote [[ return _G.DRIVE.reloads > 0 ]], 'the change was never noticed' )

  local diffed = remote [[
    vim.api.nvim_buf_set_lines( 0, 0, 1, false, { 'ONLY IN THE BUFFER' } )
    vim.cmd 'DiffDisk'
    -- `:DiffDisk` leaves the cursor in the original window
    local scratch = vim.api.nvim_get_current_buf()
    local in_diff = 0
    local disk
    for _, window in ipairs( vim.api.nvim_tabpage_list_wins( 0 ) ) do
      if vim.wo[window].diff then in_diff = in_diff + 1 end
      local buffer = vim.api.nvim_win_get_buf( window )
      if buffer ~= scratch and vim.bo[buffer].buftype == 'nofile' then
        disk = vim.api.nvim_buf_get_lines( buffer, 0, 1, false )[1]
      end
    end
    vim.cmd 'only'
    vim.cmd 'silent! edit!'
    return { in_diff = in_diff, disk = disk }
  ]]

  expect( diffed.in_diff == 2, 'expected two windows in diff mode, got ' .. tostring( diffed.in_diff ) )
  expect( diffed.disk == '501', 'the scratch window does not hold the file on disk: ' .. tostring( diffed.disk ) )
end )

--[[ TEARDOWN ]]

pcall( vim.fn.chanclose, channel )
server:kill 'sigterm'
vim.fn.delete( directory, 'rf' )

if #failures > 0 then
  print( tostring( #failures ) .. ' scenario(s) failed' )
  os.exit( 1 )
end

print 'all file change scenarios passed'