diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 8cea4e2671..005b6b2b8b 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -809,12 +809,13 @@ A jump table for the options with a short description can be found at |Q_op|. *'autoread'* *'ar'* *'noautoread'* *'noar'* 'autoread' 'ar' boolean (default on) global or local to buffer |global-local| - When a file has been detected to have been changed outside of Vim and - it has not been changed inside of Vim, automatically read it again. - When the file has been deleted this is not done, so you have the text - from before it was deleted. When it appears again then it is read. - Nvim uses file system watchers to detect changes in real-time for all - loaded buffers; see |timestamp| for details. + When a file was changed outside of Nvim, automatically read it again. + Skipped if the file was deleted, so you have the text from before it + was deleted. If the file appears again then it is read. |timestamp| + + This is partially driven by OS filewatcher events |uv_fs_event_t|, so + even the current buffer may be updated. + If this option has a local value, use this command to switch back to using the global value: >vim set autoread< diff --git a/runtime/lua/nvim/autoread.lua b/runtime/lua/nvim/autoread.lua index fc36d84218..229e2f6d26 100644 --- a/runtime/lua/nvim/autoread.lua +++ b/runtime/lua/nvim/autoread.lua @@ -14,7 +14,21 @@ local watchers = {} --- @type table bufnr -> debounce timer local timers = {} -local DEBOUNCE_MS = 100 +local debounce_ms = 100 + +--- @private +--- Test-only: override the debounce window so tests can run faster. +--- @param ms integer +function M._set_debounce(ms) + debounce_ms = ms +end + +--- @private +--- @param bufnr integer +--- @return boolean +function M._is_watching(bufnr) + return watchers[bufnr] ~= nil +end --- Returns the effective 'autoread' value for a buffer. --- 'autoread' is global-local: vim.bo[bufnr].autoread is nil when not set locally, @@ -84,7 +98,7 @@ local function ensure_watcher(bufnr) local cancel = watch.watch(name, {}, function(_, change_type) -- Debounce: restart the same timer on each event, so only the last -- event in a rapid series (e.g. truncate + write) triggers checktime. - timer:start(DEBOUNCE_MS, 0, function() + timer:start(debounce_ms, 0, function() vim.schedule(function() if not vim.api.nvim_buf_is_loaded(bufnr) or not buf_autoread(bufnr) then return @@ -102,13 +116,6 @@ local function ensure_watcher(bufnr) watchers[bufnr] = cancel end ---- @private ---- @param bufnr integer ---- @return boolean -function M._is_watching(bufnr) - return watchers[bufnr] ~= nil -end - function M.enable() local group = vim.api.nvim_create_augroup('nvim.autoread', { clear = true }) diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index 5bc33d2ede..2b8c298972 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -168,12 +168,13 @@ vim.o.ai = vim.o.autoindent vim.bo.autoindent = vim.o.autoindent vim.bo.ai = vim.bo.autoindent ---- When a file has been detected to have been changed outside of Vim and ---- it has not been changed inside of Vim, automatically read it again. ---- When the file has been deleted this is not done, so you have the text ---- from before it was deleted. When it appears again then it is read. ---- Nvim uses file system watchers to detect changes in real-time for all ---- loaded buffers; see `timestamp` for details. +--- When a file was changed outside of Nvim, automatically read it again. +--- Skipped if the file was deleted, so you have the text from before it +--- was deleted. If the file appears again then it is read. `timestamp` +--- +--- This is partially driven by OS filewatcher events `uv_fs_event_t`, so +--- even the current buffer may be updated. +--- --- If this option has a local value, use this command to switch back to --- using the global value: --- diff --git a/src/nvim/options.lua b/src/nvim/options.lua index f02318a546..d3cd1fc85d 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -301,12 +301,13 @@ local options = { abbreviation = 'ar', defaults = true, desc = [=[ - When a file has been detected to have been changed outside of Vim and - it has not been changed inside of Vim, automatically read it again. - When the file has been deleted this is not done, so you have the text - from before it was deleted. When it appears again then it is read. - Nvim uses file system watchers to detect changes in real-time for all - loaded buffers; see |timestamp| for details. + When a file was changed outside of Nvim, automatically read it again. + Skipped if the file was deleted, so you have the text from before it + was deleted. If the file appears again then it is read. |timestamp| + + This is partially driven by OS filewatcher events |uv_fs_event_t|, so + even the current buffer may be updated. + If this option has a local value, use this command to switch back to using the global value: >vim set autoread< diff --git a/test/functional/options/autoread_spec.lua b/test/functional/options/autoread_spec.lua index 0491b90f43..841719c13e 100644 --- a/test/functional/options/autoread_spec.lua +++ b/test/functional/options/autoread_spec.lua @@ -9,8 +9,6 @@ local retry = t.retry local write_file = t.write_file local sleep = vim.uv.sleep -local testdir = 'Xtest-autoread' - --- Returns true if the autoread module is watching the given buffer --- (defaults to the current buffer). local function is_watching(bufnr) @@ -19,70 +17,88 @@ local function is_watching(bufnr) end, bufnr) end +--- Shortens the 'autoread' debounce window so each test doesn't pay the 100ms time-cost. +local function shorten_debounce() + n.exec_lua([[require('nvim.autoread')._set_debounce(10)]]) +end + +--- Edits a fresh tempfile with the given initial content and asserts the watcher attached. +--- Returns the file path. +local function open_watched(content) + local path = t.tmpname() + write_file(path, content) + command('edit ' .. path) + eq(true, is_watching()) + return path +end + describe('autoread file watcher', function() before_each(function() - n.mkdir_p(testdir) clear({ args = { '--clean' } }) - end) - - after_each(function() - n.rmdir(testdir) + shorten_debounce() end) it('watches file opened on startup (nvim foo.txt)', function() - local path = testdir .. '/test_startup.txt' + local path = t.tmpname() write_file(path, 'startup original\n') -- Spawn nvim with the file passed on the command line. This exercises the -- boot order: plugins must load before the initial file is read so that -- the BufReadPost autocmd is registered in time to attach a watcher. clear({ args = { '--clean', path } }) + shorten_debounce() eq({ 'startup original' }, api.nvim_buf_get_lines(0, 0, -1, true)) eq(true, is_watching()) - -- Modify file externally; watcher should already be active. write_file(path, 'startup changed\n') - retry(nil, 3000, function() eq({ 'startup changed' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) end) - it('reloads buffer when file changes externally', function() - local path = testdir .. '/test_reload.txt' - write_file(path, 'original content\n') + it('reloads on external change; survives hide; undoable; bdelete stops watch', function() + local path = open_watched('original content\n') + local bufnr = api.nvim_get_current_buf() - command('edit ' .. path) - eq({ 'original content' }, api.nvim_buf_get_lines(0, 0, -1, true)) - eq(true, is_watching()) - - -- Modify file externally + -- 1. Plain external change reloads the visible buffer. write_file(path, 'new content\n') - - -- The watcher + debounce should trigger checktime retry(nil, 3000, function() eq({ 'new content' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) + + -- 2. Hide the buffer; watcher stays attached and still reloads. + command('set hidden') + command('enew') + eq(true, is_watching(bufnr)) + write_file(path, 'while hidden\n') + retry(nil, 3000, function() + eq({ 'while hidden' }, api.nvim_buf_get_lines(bufnr, 0, -1, true)) + end) + + -- 3. The reload is undoable. Done last so the resulting modified state + -- (buffer ≠ disk) doesn't block earlier auto-reload assertions. + command('buffer ' .. bufnr) + command('silent undo') + eq({ 'new content' }, api.nvim_buf_get_lines(0, 0, -1, true)) + + -- 4. bdelete stops the watcher. + command('enew!') + command('bdelete! ' .. bufnr) + eq(false, is_watching(bufnr)) end) it('does not reload when buffer has unsaved changes (conflict)', function() - local path = testdir .. '/test_conflict.txt' - write_file(path, 'original\n') + local path = open_watched('original\n') - command('edit ' .. path) - eq(true, is_watching()) - - -- Make a local change so the buffer is modified api.nvim_buf_set_lines(0, 0, -1, true, { 'local change' }) eq(true, api.nvim_get_option_value('modified', { buf = 0 })) - -- Modify file externally write_file(path, 'external change\n') - -- Give watcher time to fire; buffer should NOT be reloaded - -- because it has unsaved changes (autoread only reloads unmodified buffers) - sleep(200) + -- Give the watcher time to fire; the buffer must NOT be reloaded because + -- it has unsaved changes (autoread only reloads unmodified buffers). + sleep(50) -- Also do a manual checktime to be sure command('silent! checktime') -- Buffer should still have local changes (autoread doesn't override modified buffers) @@ -90,17 +106,14 @@ describe('autoread file watcher', function() end) it('tracks autoread option changes', function() - local path = testdir .. '/test_reenable.txt' - write_file(path, 'original\n') + local path = open_watched('original\n') - command('edit ' .. path) - eq(true, is_watching()) command('setlocal noautoread') eq(false, is_watching()) -- Modify externally while noautoread write_file(path, 'while disabled\n') - sleep(200) + sleep(50) eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) -- Re-enable autoread @@ -109,93 +122,55 @@ describe('autoread file watcher', function() -- Modify again write_file(path, 'after reenable\n') - retry(nil, 3000, function() eq({ 'after reenable' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) end) - it('stops watcher on bdelete', function() - local path = testdir .. '/test_bdelete.txt' - write_file(path, 'content\n') - - command('edit ' .. path) - local bufnr = api.nvim_get_current_buf() - eq(true, is_watching(bufnr)) - - command('enew') - command('bdelete ' .. bufnr) - eq(false, is_watching(bufnr)) - end) - - it('reloads hidden buffer when file changes', function() - local path = testdir .. '/test_hidden.txt' - write_file(path, 'original\n') - - command('set hidden') - command('edit ' .. path) - local bufnr = api.nvim_get_current_buf() - eq(true, is_watching(bufnr)) - - -- Switch to a different buffer (hides the first one) - command('enew') - eq(true, is_watching(bufnr)) - - -- Modify file externally - write_file(path, 'updated hidden\n') - - retry(nil, 3000, function() - eq({ 'updated hidden' }, api.nvim_buf_get_lines(bufnr, 0, -1, true)) - end) - end) - it('handles file deletion gracefully', function() - local path = testdir .. '/test_delete.txt' - write_file(path, 'will be deleted\n') + local path = open_watched('will be deleted\n') - command('edit ' .. path) - eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true)) - eq(true, is_watching()) - - -- Delete the file os.remove(path) retry(nil, 3000, function() eq(false, is_watching()) end) - -- Buffer content should remain unchanged + -- Buffer content remains unchanged. eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) - it('handles rapid changes with debouncing', function() - local path = testdir .. '/test_debounce.txt' - write_file(path, 'v1\n') + it('coalesces rapid changes via debouncing', function() + local path = open_watched('v1\n') - command('edit ' .. path) - eq({ 'v1' }, api.nvim_buf_get_lines(0, 0, -1, true)) - eq(true, is_watching()) + -- Count buffer reloads triggered by the watcher. + n.exec_lua([[ + _G.reloads = 0 + vim.api.nvim_create_autocmd('FileChangedShellPost', { + callback = function() _G.reloads = _G.reloads + 1 end, + }) + ]]) - -- Make several rapid changes + -- 4 back-to-back writes well inside one debounce window. write_file(path, 'v2\n') write_file(path, 'v3\n') write_file(path, 'v4\n') write_file(path, 'final\n') - -- Should eventually settle on final content retry(nil, 3000, function() eq({ 'final' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) + + -- Let any late-arriving event flush, then assert all 4 writes coalesced. + -- Every fs_event restarts the debounce timer, and 4 sub-millisecond + -- write_file calls fit well inside one window, so the timer fires once. + sleep(50) + eq(1, n.exec_lua('return _G.reloads')) end) it('detects changes after atomic rename (external editor save)', function() - local path = testdir .. '/test_rename.txt' - write_file(path, 'original\n') + local path = open_watched('original\n') - command('edit ' .. path) - eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) - eq(true, is_watching()) - - -- Simulate atomic save: write to temp file, rename over target + -- Atomic save: write to temp file, rename over target. local tmp = path .. '.tmp' write_file(tmp, 'after rename\n') assert(vim.uv.fs_rename(tmp, path)) @@ -203,34 +178,13 @@ describe('autoread file watcher', function() retry(nil, 3000, function() eq({ 'after rename' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) - -- Watcher should have been re-established on the new inode. + -- Watcher re-established on the new inode. eq(true, is_watching()) - -- Verify the watcher still works for subsequent plain writes + -- Subsequent plain writes still reload. write_file(path, 'second change\n') - retry(nil, 3000, function() eq({ 'second change' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) end) - - it('auto-reload is undoable', function() - local path = testdir .. '/test_undo.txt' - write_file(path, 'original\n') - - command('edit ' .. path) - eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) - eq(true, is_watching()) - - -- Modify externally - write_file(path, 'changed externally\n') - - retry(nil, 3000, function() - eq({ 'changed externally' }, api.nvim_buf_get_lines(0, 0, -1, true)) - end) - - -- Undo should restore original content - command('silent undo') - eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true)) - end) end)