---@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'