Files
neovim/runtime/lua/vim/_watchdirs.lua
T
Lewis Russell d2c8b21619 refactor(watch): extract watchdirs into a class
Problem: watchdirs() keeps its state in large nested callbacks in
_watch.lua, which makes its directory tracking logic harder to change.

Solution: Move the backend into its own module and store its state in a
WatchDirs instance. Split setup, event handling and cleanup into
methods. Extend the existing test to cover cancellation while a
directory stat is pending.

AI-assisted
2026-10-16 19:29:39 +01:00

232 lines
6.3 KiB
Lua

local uv = vim.uv
--- Can this directory contain matching paths? Keep ancestors so that matching directories
--- created later can still be discovered. Every nonempty root ends in '/', so '/src/'
--- cannot cover '/src2/'.
--- @param path string
--- @param filter vim._watch.CompiledFilter
--- @return boolean
local function matches_dir(path, filter)
local dir = path:gsub('/$', '') .. '/'
for _, root in ipairs(filter.roots) do
if vim.startswith(dir, root) or vim.startswith(root, dir) then
return true
end
end
return false
end
--- @class (private) vim._watch.WatchDirs
--- @field private opts vim._watch.BackendOpts
--- @field private callback vim._watch.Callback
--- @field private handles table<string, uv.uv_fs_event_t> Handles by full path.
--- @field private timer uv.uv_timer_t
--- @field private cancelled boolean
--- @field private filechanges table<string, boolean> Whether each path changed during the debounce cycle.
local WatchDirs = {}
WatchDirs.__index = WatchDirs
--- @param opts vim._watch.BackendOpts
--- @param callback vim._watch.Callback
--- @return vim._watch.WatchDirs
function WatchDirs.new(opts, callback)
return setmetatable({
opts = opts,
callback = callback,
handles = {},
timer = assert(uv.new_timer()),
cancelled = false,
filechanges = {},
}, WatchDirs)
end
--- @private
--- Decides if `path` should be skipped.
--- @param path string
--- @param directory? boolean Check whether the directory can contain matching paths.
--- @return boolean
function WatchDirs:skip(path, directory)
if directory then
if not matches_dir(path, self.opts.include) then
return true
end
elseif self.opts.include.pattern:match(path) == nil then
return true
end
return self.opts.exclude ~= nil and self.opts.exclude.pattern:match(path) ~= nil
end
--- @private
--- @param path string
--- @return string? err
--- @return string? errname
function WatchDirs:add_watch(path)
if self.handles[path] then
return
end
local handle = assert(uv.new_fs_event())
self.handles[path] = handle
local _, start_err, errname = handle:start(path, {}, function(err, filename, events)
self:on_change(path, err, filename, events)
end)
if start_err then
handle:close()
self.handles[path] = nil
end
return start_err, errname
end
--- @private
--- @param path string
function WatchDirs:remove_watch(path)
local handle = self.handles[path]
if handle then
if not handle:is_closing() then
handle:close()
end
self.handles[path] = nil
end
end
--- @private
--- @param path string
--- @param err string?
--- @param filename string
--- @param events {change?: boolean, rename?: boolean}
function WatchDirs:on_change(path, err, filename, events)
if self.cancelled then
return
end
if err then
return self.opts.on_error(err)
end
local fullpath = vim.fs.joinpath(path, filename)
if self:skip(fullpath) then
-- An unmatched directory can contain matching descendants. Track its creation/deletion
-- even when no event for the directory itself will be reported.
if
self:skip(fullpath, true)
or not events.rename
or (not self.handles[fullpath] and (uv.fs_stat(fullpath) or {}).type ~= 'directory')
then
return
end
end
if not self.filechanges[fullpath] then
self.filechanges[fullpath] = events.change or false
end
self.timer:start(self.opts.debounce or 500, 0, function()
-- Since the callback is debounced it may have also been deleted later on
-- so we always need to check the existence of the file:
-- stat succeeds, changed=true -> Changed
-- stat succeeds, changed=false -> Created
-- stat fails -> Removed
for changed_path, changed in pairs(self.filechanges) do
uv.fs_stat(changed_path, function(_, stat)
self:process_change(changed_path, changed, stat)
end)
end
self.filechanges = {}
end)
end
--- @private
--- @param fullpath string
--- @param changed boolean
--- @param stat uv.fs_stat.result?
function WatchDirs:process_change(fullpath, changed, stat)
if self.cancelled then
return
end
local FileChangeType = vim._watch.FileChangeType
--- @type vim._watch.FileChangeType
local change_type
if stat then
change_type = changed and FileChangeType.Changed or FileChangeType.Created
if stat.type == 'directory' then
local err, errname = self:add_watch(fullpath)
-- The directory may have disappeared since fs_stat().
if err and errname ~= 'ENOENT' then
self.opts.on_error(err)
end
end
else
change_type = FileChangeType.Deleted
self:remove_watch(fullpath)
end
if not self:skip(fullpath) then
self.callback(fullpath, change_type)
end
end
--- @package
--- @param path string The root directory to watch.
--- @return boolean started
function WatchDirs:start(path)
local start_err = self:add_watch(path)
if start_err then
self:stop()
self.opts.on_error(start_err)
return false
end
--- "640K ought to be enough for anyone"
--- Who has folders this deep?
local max_depth = 100
for name, type in
vim.fs.dir(path, {
depth = max_depth,
skip = function(dir)
return not self:skip(vim.fs.joinpath(path, dir), true)
end,
})
do
if type == 'directory' then
local filepath = vim.fs.joinpath(path, name)
if not self:skip(filepath, true) then
local err, errname = self:add_watch(filepath)
-- The directory may have disappeared since it was listed.
if err and errname ~= 'ENOENT' then
self.opts.on_error(err)
end
end
end
end
return true
end
--- @package
function WatchDirs:stop()
self.cancelled = true
for path in pairs(self.handles) do
self:remove_watch(path)
end
self.timer:stop()
self.timer:close()
end
--- Initializes and starts a |uv_fs_event_t| recursively watching every directory underneath the
--- directory at path.
---
--- @param path string The path to watch. Must refer to a directory.
--- @param opts vim._watch.BackendOpts Additional options
--- @param callback vim._watch.Callback Callback for new events
--- @return fun()? cancel Stops all directory watchers; nil if startup failed.
return function(path, opts, callback)
local watcher = WatchDirs.new(opts, callback)
if not watcher:start(path) then
return
end
return function()
watcher:stop()
end
end