aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/_watch.lua
diff options
context:
space:
mode:
authorLewis Russell <lewis6991@gmail.com>2026-09-07 16:21:52 +0100
committerLewis Russell <me@lewisr.dev>2026-09-24 20:36:55 +0100
commitc4efa259b4aa0bfee0c40a5b67fe5da548f51d85 (patch)
tree384513d65058b58eaf39337db685cd985623986d /runtime/lua/vim/_watch.lua
parent8aae4c104640bc4fd17fd51b2eb41efe7ee460cd (diff)
downloadrneovim-c4efa259b4aa0bfee0c40a5b67fe5da548f51d85.tar.gz
rneovim-c4efa259b4aa0bfee0c40a5b67fe5da548f51d85.tar.bz2
rneovim-c4efa259b4aa0bfee0c40a5b67fe5da548f51d85.zip
perf(lua): share compatible file watchers
Problem: LSP servers can register identical file watchers before removing old registrations. Each registration creates another watcher tree, making setup and teardown expensive in large workspaces. Solution: Move glob compilation into vim._watch and accept rules for multiple roots alongside existing single-path and LPeg inputs. Share compatible backends until their last subscription is cancelled. Ref: #23291 AI-assisted
Diffstat (limited to 'runtime/lua/vim/_watch.lua')
-rw-r--r--runtime/lua/vim/_watch.lua434
1 files changed, 353 insertions, 81 deletions
diff --git a/runtime/lua/vim/_watch.lua b/runtime/lua/vim/_watch.lua
index 2cd61b36ab..bdb5882150 100644
--- a/runtime/lua/vim/_watch.lua
+++ b/runtime/lua/vim/_watch.lua
@@ -11,62 +11,302 @@ M.FileChangeType = {
Deleted = 3,
}
---- Count of currently-active watchers. Each "watcher" here is one call to vim._watch.watch(),
---- vim._watch.watchdirs(), or vim._watch.inotify() that hasn't been cancelled yet.
-M.active = { watch = 0, watchdirs = 0, inotify = 0 }
-
---- Wraps a cancel function so the global counter is decremented exactly once, even if the caller
---- invokes the cancel function multiple times.
---- @param backend 'watch'|'watchdirs'|'inotify'
---- @param cancel fun()
---- @return fun()
-local function tracked_cancel(backend, cancel)
- M.active[backend] = M.active[backend] + 1
- local done = false
- return function()
- if done then
- return
- end
- done = true
- M.active[backend] = M.active[backend] - 1
- cancel()
- end
-end
+--- A glob string or compiled LPeg pattern. Strings match full paths using `/` separators.
+--- Glob syntax follows [vim.glob] (LSP 3.17).
+--- @alias vim._watch.Pattern string|vim.lpeg.Pattern
+
+--- A list matches if any of its patterns matches. An empty list matches nothing.
+--- @alias vim._watch.Filter vim._watch.Pattern|vim._watch.Pattern[]
+--- Option and rule tables must not be modified while watching.
--- @class vim._watch.Opts
---
--- @field debounce? integer ms
---
---- Handles watcher failures. Defaults to logging at ERROR in nvim-watch.log.
+--- Handles watcher failures and invalid globs, with root and pattern context in the message.
+--- Invalid globs are skipped. Defaults to logging at ERROR in nvim-watch.log.
--- @field on_error? fun(err: string)
---
---- An |lpeg| pattern. Only changes to files whose full paths match the pattern
---- will be reported. Only matches against non-directoriess, all directories will
---- be watched for new potentially-matching files. exclude_pattern can be used to
---- filter out directories. When nil, matches any file name.
---- @field include_pattern? vim.lpeg.Pattern
+--- Only changes matching this filter are reported. Parent directories remain watched for matching
+--- descendants. See vim._watch.Pattern for glob syntax. When nil, matches any path.
+--- @field include_pattern? vim._watch.Filter
---
---- An |lpeg| pattern. Only changes to files and directories whose full path does
---- not match the pattern will be reported. Matches against both files and
---- directories. With watchdirs(), excluding a subdirectory excludes its entire
---- subtree. When nil, matches nothing.
---- @field exclude_pattern? vim.lpeg.Pattern
+--- Changes matching this filter are not reported. With watchdirs(), excluding a subdirectory
+--- excludes its entire subtree. When nil, matches nothing.
+--- @field exclude_pattern? vim._watch.Filter
--- @alias vim._watch.Callback fun(path: string, change_type: vim._watch.FileChangeType)
--- @class vim._watch.watch.Opts : vim._watch.Opts
---- @field uvflags? uv.fs_event_start.flags
+--- @field uvflags? uv.fs_event_start.flags See |uv.fs_event_start()|.
---- Decides if `path` should be skipped.
----
+--- Rules sharing a root use one backend watcher. Each event is reported once per root if any
+--- rule matches, subject to the include_pattern and exclude_pattern options.
+--- @class vim._watch.Rule
+--- @field path string Root to watch.
+--- @field pattern? vim._watch.Filter Matches full paths; nil matches everything.
+--- @field events? vim._watch.FileChangeType[] Defaults to all event types.
+
+--- @class (private) vim._watch.BackendOpts : vim._watch.watch.Opts
+--- @field include_pattern? vim.lpeg.Pattern
+--- @field exclude_pattern? vim.lpeg.Pattern
+--- @field on_error fun(err: string)
+
+--- @alias vim._watch.Backend fun(path: string, opts: vim._watch.BackendOpts, callback: vim._watch.Callback): (fun())?
+
+--- @class (private) vim._watch.Subscriber
+--- @field callback vim._watch.Callback
+--- @field on_error fun(err: string)
+
+--- @class (private) vim._watch.Shared
+--- @field key table
+--- @field stat? uv.fs_stat.result
+--- @field failed boolean
+--- @field subscribers table<vim._watch.Subscriber, true>
+--- @field cancel? fun()
+
+--- @type table<string, table<string, table<vim._watch.Shared, true>>>
+local shared = { watch = {}, watchdirs = {}, inotify = {} }
+
+--- Counts backend watchers with outstanding subscriptions, excluding failed starts.
+--- @return {watch: integer, watchdirs: integer, inotify: integer}
+function M.active()
+ local active = { watch = 0, watchdirs = 0, inotify = 0 }
+
+ for name, paths in pairs(shared) do
+ for _, entries in pairs(paths) do
+ active[name] = active[name] + vim.tbl_count(entries)
+ end
+ end
+
+ return active
+end
+
+--- An omitted filter matches everything; invalid or empty filters return nil.
+--- @param filter vim._watch.Filter?
+--- @param on_error fun(err: string)
+--- @return vim.lpeg.Pattern?
+local function compile(filter, on_error)
+ if filter == nil then
+ return vim.lpeg.P(true)
+ elseif type(filter) == 'userdata' then
+ return filter --[[@as vim.lpeg.Pattern]]
+ elseif type(filter) == 'string' then
+ local ok, pattern = pcall(vim.glob.to_lpeg, filter)
+ if not ok then
+ on_error(('Invalid glob %q: %s'):format(filter, pattern))
+ return nil
+ end
+
+ return pattern
+ end
+
+ local pattern --- @type vim.lpeg.Pattern?
+
+ for _, item in ipairs(filter) do
+ local p = compile(item, on_error)
+ if p then
+ pattern = pattern and pattern + p or p
+ end
+ end
+
+ return pattern
+end
+
+--- @param entry vim._watch.Shared
+--- @param method 'callback'|'on_error'
+--- @param ... any
+local function emit(entry, method, ...)
+ local first_error
+
+ -- Callbacks can subscribe or cancel other subscriptions while handling an event.
+ for _, subscriber in ipairs(vim.tbl_keys(entry.subscribers)) do
+ if entry.subscribers[subscriber] then
+ local ok, err = pcall(subscriber[method], ...)
+ if not ok then
+ first_error = first_error or tostring(err)
+ end
+ end
+ end
+
+ if first_error then
+ error(first_error)
+ end
+end
+
+--- @param name 'watch'|'watchdirs'|'inotify'
+--- @param backend vim._watch.Backend
--- @param path string
---- @param opts? vim._watch.Opts
-local function skip(path, opts)
- if not opts then
- return false
+--- @param opts vim._watch.watch.Opts
+--- @param rules {pattern: vim._watch.Filter?, events: vim._watch.FileChangeType[]?}[]
+--- @param subscriber vim._watch.Subscriber
+--- @return fun()
+local function subscribe(name, backend, path, opts, rules, subscriber)
+ local key = {
+ include_pattern = opts.include_pattern,
+ exclude_pattern = opts.exclude_pattern,
+ debounce = name == 'watchdirs' and (opts.debounce or 500) or nil,
+ uvflags = name == 'watch' and (opts.uvflags or {}) or nil,
+ rules = rules,
+ }
+
+ -- Validate every subscription, even when reusing a backend. Invalid globs do not mean the
+ -- backend has failed, and each subscriber should receive its own diagnostics.
+ local include_pattern = compile(key.include_pattern, subscriber.on_error)
+ local exclude_pattern = key.exclude_pattern and compile(key.exclude_pattern, subscriber.on_error)
+ local compiled = {} --- @type {pattern: vim.lpeg.Pattern, events: vim._watch.FileChangeType[]?}[]
+ local include = vim.lpeg.P(false)
+
+ for _, rule in ipairs(key.rules) do
+ local pattern = compile(rule.pattern, subscriber.on_error)
+ if pattern then
+ compiled[#compiled + 1] = { pattern = pattern, events = rule.events }
+ include = include + pattern
+ end
+ end
+
+ if not include_pattern or #compiled == 0 then
+ return function() end
+ end
+
+ local stat = uv.fs_stat(path)
+ local entry --- @type vim._watch.Shared?
+
+ for r in pairs(shared[name][path] or {}) do
+ if
+ not r.failed
+ and stat
+ and r.stat
+ and stat.dev == r.stat.dev
+ and stat.ino == r.stat.ino
+ and vim.deep_equal(r.key, key)
+ then
+ entry = r
+ break
+ end
+ end
+
+ if entry then
+ entry.subscribers[subscriber] = true
+ else
+ entry = { key = key, stat = stat, failed = false, subscribers = { [subscriber] = true } }
+
+ --- @type vim._watch.BackendOpts
+ local backend_opts = {
+ debounce = key.debounce,
+ uvflags = key.uvflags,
+ -- Both filters must match from the beginning of the full path.
+ include_pattern = #include_pattern * include,
+ exclude_pattern = exclude_pattern,
+ on_error = function(err)
+ entry.failed = true
+ emit(entry, 'on_error', err)
+ end,
+ }
+
+ entry.cancel = backend(path, backend_opts, function(fullpath, change_type)
+ for _, rule in ipairs(compiled) do
+ if
+ rule.pattern:match(fullpath) ~= nil
+ and (not rule.events or vim.list_contains(rule.events, change_type))
+ then
+ emit(entry, 'callback', fullpath, change_type)
+ return
+ end
+ end
+ end)
+
+ if not entry.cancel then
+ return function() end
+ end
+
+ -- Publish only after setup completes, so reentrant calls cannot reuse partial watchers.
+ shared[name][path] = shared[name][path] or {}
+ shared[name][path][entry] = true
end
- if opts.include_pattern and opts.include_pattern:match(path) == nil then
+ return function()
+ if not entry.subscribers[subscriber] then
+ return
+ end
+
+ entry.subscribers[subscriber] = nil
+
+ if not next(entry.subscribers) then
+ shared[name][path][entry] = nil
+ if not next(shared[name][path]) then
+ shared[name][path] = nil
+ end
+
+ entry.cancel()
+ end
+ end
+end
+
+--- Compatible calls share a backend until their last subscription is cancelled. Glob descriptions
+--- are compared by value, LPeg patterns by identity. Failed or replaced roots are never reused.
+--- @param name 'watch'|'watchdirs'|'inotify'
+--- @param backend vim._watch.Backend
+--- @param path string|vim._watch.Rule[]
+--- @param opts vim._watch.watch.Opts?
+--- @param callback vim._watch.Callback
+--- @return fun()
+local function start_watch(name, backend, path, opts, callback)
+ vim.validate('path', path, { 'string', 'table' })
+ vim.validate('opts', opts, 'table', true)
+ vim.validate('callback', callback, 'function')
+
+ opts = opts or {}
+ local on_error = opts.on_error or log.error
+
+ if type(path) == 'string' then
+ path = { { path = path } }
+ end
+
+ --- @type table<string, {pattern: vim._watch.Filter?, events: vim._watch.FileChangeType[]?}[]>
+ local by_path = vim.defaulttable()
+
+ for _, rule in ipairs(path) do
+ table.insert(
+ by_path[vim.fs.normalize(rule.path)],
+ { pattern = rule.pattern, events = rule.events }
+ )
+ end
+
+ local cancels = {} --- @type (fun())[]
+
+ local function cancel_all()
+ for _, cancel in ipairs(cancels) do
+ cancel()
+ end
+ end
+
+ for root, rules in pairs(by_path) do
+ local ok, result = pcall(subscribe, name, backend, root, opts, rules, {
+ callback = callback,
+ on_error = function(err)
+ on_error(('%s (watching %s)'):format(err, root))
+ end,
+ })
+
+ if not ok then
+ cancel_all()
+ error(result, 0)
+ end
+
+ cancels[#cancels + 1] = result
+ end
+
+ return cancel_all
+end
+
+--- Decides if `path` should be skipped.
+---
+--- @param path string
+--- @param opts vim._watch.BackendOpts
+--- @param directory? boolean Ignore include_pattern when traversing directories.
+local function skip(path, opts, directory)
+ if not directory and opts.include_pattern and opts.include_pattern:match(path) == nil then
return true
end
@@ -80,26 +320,18 @@ end
--- Initializes and starts a |uv_fs_event_t|
---
--- @param path string The path to watch
---- @param opts vim._watch.watch.Opts? Additional options:
+--- @param opts vim._watch.BackendOpts Additional options:
--- - uvflags (table|nil)
--- Same flags as accepted by |uv.fs_event_start()|
--- @param callback vim._watch.Callback Callback for new events
---- @return fun() cancel Stops the watcher
-function M.watch(path, opts, callback)
- vim.validate('path', path, 'string')
- vim.validate('opts', opts, 'table', true)
- vim.validate('callback', callback, 'function')
-
- opts = opts or {}
- local on_error = opts.on_error or log.error
-
- path = vim.fs.normalize(path)
- local uvflags = opts and opts.uvflags or {}
+--- @return fun()? cancel Stops the watcher; nil if startup failed.
+local function watch(path, opts, callback)
+ local on_error = opts.on_error
local handle = assert(uv.new_fs_event())
local watching_dir = (uv.fs_stat(path) or {}).type == 'directory'
- local _, start_err = handle:start(path, uvflags, function(err, filename, events)
+ local _, start_err = handle:start(path, opts.uvflags or {}, function(err, filename, events)
if err then
return on_error(err)
end
@@ -133,10 +365,10 @@ function M.watch(path, opts, callback)
if start_err then
handle:close()
on_error(start_err)
- return function() end
+ return nil
end
- return tracked_cancel('watch', function()
+ return function()
local _, stop_err = handle:stop()
assert(not stop_err, stop_err)
local is_closing, close_err = handle:is_closing()
@@ -144,24 +376,20 @@ function M.watch(path, opts, callback)
if not is_closing then
handle:close()
end
- end)
+ end
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.Opts? Additional options
+--- @param opts vim._watch.BackendOpts Additional options
--- @param callback vim._watch.Callback Callback for new events
---- @return fun() cancel Stops the watcher
-function M.watchdirs(path, opts, callback)
- vim.validate('path', path, 'string')
- vim.validate('opts', opts, 'table', true)
- vim.validate('callback', callback, 'function')
-
- opts = opts or {}
- local on_error = opts.on_error or log.error
+--- @return fun()? cancel Stops all directory watchers; nil if startup failed.
+local function watchdirs(path, opts, callback)
+ local on_error = opts.on_error
local debounce = opts.debounce or 500
+ local cancelled = false
---@type table<string, uv.uv_fs_event_t> handle by fullpath
local handles = {}
@@ -179,12 +407,24 @@ function M.watchdirs(path, opts, callback)
--- @return uv.fs_event_start.callback
local function create_on_change(filepath)
return function(err, filename, events)
+ if cancelled then
+ return
+ end
if err then
return on_error(err)
end
+
local fullpath = vim.fs.joinpath(filepath, filename)
if skip(fullpath, opts) then
- return
+ -- An unmatched directory can contain matching descendants. Track its creation/deletion
+ -- even when no event for the directory itself will be reported.
+ if
+ skip(fullpath, opts, true)
+ or not events.rename
+ or (not handles[fullpath] and (uv.fs_stat(fullpath) or {}).type ~= 'directory')
+ then
+ return
+ end
end
if not filechanges[fullpath] then
@@ -202,6 +442,10 @@ function M.watchdirs(path, opts, callback)
-- stat fails -> Removed
for fullpath, changed in pairs(filechanges) do
uv.fs_stat(fullpath, function(_, stat)
+ if cancelled then
+ return
+ end
+
---@type vim._watch.FileChangeType
local change_type
if stat then
@@ -232,7 +476,10 @@ function M.watchdirs(path, opts, callback)
handles[fullpath] = nil
end
end
- callback(fullpath, change_type)
+
+ if not skip(fullpath, opts) then
+ callback(fullpath, change_type)
+ end
end)
end
filechanges = {}
@@ -246,7 +493,7 @@ function M.watchdirs(path, opts, callback)
root_handle:close()
timer:close()
on_error(start_err)
- return function() end
+ return nil
end
--- "640K ought to be enough for anyone"
@@ -264,7 +511,7 @@ function M.watchdirs(path, opts, callback)
do
if type == 'directory' then
local filepath = vim.fs.joinpath(path, name)
- if not skip(filepath, opts) then
+ if not skip(filepath, opts, true) then
local handle = assert(uv.new_fs_event())
handles[filepath] = handle
local _, err, errname = handle:start(filepath, {}, create_on_change(filepath))
@@ -280,7 +527,8 @@ function M.watchdirs(path, opts, callback)
end
end
- local function cancel()
+ return function()
+ cancelled = true
for fullpath, handle in pairs(handles) do
if not handle:is_closing() then
handle:close()
@@ -290,12 +538,10 @@ function M.watchdirs(path, opts, callback)
timer:stop()
timer:close()
end
-
- return tracked_cancel('watchdirs', cancel)
end
--- @param data string
---- @param opts vim._watch.Opts?
+--- @param opts vim._watch.BackendOpts
--- @param callback vim._watch.Callback
local function on_inotifywait_output(data, opts, callback)
local d = vim.split(data, '%s+')
@@ -329,12 +575,11 @@ local function on_inotifywait_output(data, opts, callback)
end
--- @param path string The path to watch. Must refer to a directory.
---- @param opts vim._watch.Opts?
+--- @param opts vim._watch.BackendOpts
--- @param callback vim._watch.Callback Callback for new events
---- @return fun() cancel Stops the watcher
-function M.inotify(path, opts, callback)
- opts = opts or {}
- local on_error = opts.on_error or log.error
+--- @return fun()? cancel Stops the process; nil if startup failed.
+local function inotify(path, opts, callback)
+ local on_error = opts.on_error
local cancelled = false
local ok, obj = pcall(vim.system, {
'inotifywait',
@@ -386,13 +631,40 @@ function M.inotify(path, opts, callback)
if not ok then
on_error(obj)
- return function() end
+ return nil
end
- return tracked_cancel('inotify', function()
+ return function()
cancelled = true
obj:kill(2)
- end)
+ end
+end
+
+--- Watches a path or set of rules. Compatible subscriptions share a backend watcher.
+--- @param path string|vim._watch.Rule[]
+--- @param opts? vim._watch.watch.Opts
+--- @param callback vim._watch.Callback
+--- @return fun() cancel Releases this subscription; the last cancellation stops the backend.
+function M.watch(path, opts, callback)
+ return start_watch('watch', watch, path, opts, callback)
+end
+
+--- Recursively watches directories. Compatible subscriptions share a backend watcher.
+--- @param path string|vim._watch.Rule[] Roots must be directories.
+--- @param opts? vim._watch.Opts
+--- @param callback vim._watch.Callback
+--- @return fun() cancel Releases this subscription; the last cancellation stops the backend.
+function M.watchdirs(path, opts, callback)
+ return start_watch('watchdirs', watchdirs, path, opts, callback)
+end
+
+--- Recursively watches directories using inotifywait. Compatible subscriptions share a process.
+--- @param path string|vim._watch.Rule[] Roots must be directories.
+--- @param opts? vim._watch.Opts
+--- @param callback vim._watch.Callback
+--- @return fun() cancel Releases this subscription; the last cancellation stops the backend.
+function M.inotify(path, opts, callback)
+ return start_watch('inotify', inotify, path, opts, callback)
end
return M