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'