neovim/runtime/lua/vim/_watch.lua
Justin M. Keyes 9e23b4e185 fix(watch): ignore nonexistent paths (ENOENT)
Problem:
The `_watch.watch()` strategy may fail if the given path does not exist:

    …/vim/_watch.lua:101: ENOENT: no such file or directory
    stack traceback:
        [C]: in function 'assert'
        …/vim/_watch.lua:101: in function <…/vim/_watch.lua:61>
        [string "<nvim>"]:5: in main chunk

- `_watch.watch()` actively asserts any error returned by `handle:start()`.
- whereas `_watch.watchdirs()` just ignores the result of `root_handle:start()`.

Servers may send "client/registerCapability" with "workspace/didChangeWatchedFiles"
item(s) (`baseUri`) which do not actually exist on the filesystem:
https://github.com/neovim/neovim/issues/28058#issuecomment-2189929424

    {
      method = "client/registerCapability",
      params = {
        registrations = { {
            method = "workspace/didChangeWatchedFiles",
            registerOptions = {
              watchers = { {
                  globPattern = {
                    baseUri = "file:///Users/does/not/exist",
                    pattern = "**/*.{ts,js,mts,mjs,cjs,cts,json,svelte}"
                  }
                },
    ...
    }

Solution:
- Remove the assert in `_watch.watch()`.
- Show a once-only message for both cases.
- More detailed logging is blocked until we have `nvim_log` / `vim.log`.

fix #28058
2024-10-02 16:41:01 +02:00

338 lines
9.7 KiB
Lua

local uv = vim.uv
local M = {}
--- @enum vim._watch.FileChangeType
--- Types of events watchers will emit.
M.FileChangeType = {
Created = 1,
Changed = 2,
Deleted = 3,
}
--- @class vim._watch.Opts
---
--- @field debounce? integer ms
---
--- 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
---
--- 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. When nil, matches nothing.
--- @field exclude_pattern? vim.lpeg.Pattern
--- @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
--- Decides if `path` should be skipped.
---
--- @param path string
--- @param opts? vim._watch.Opts
local function skip(path, opts)
if not opts then
return false
end
if opts.include_pattern and opts.include_pattern:match(path) == nil then
return true
end
if opts.exclude_pattern and opts.exclude_pattern:match(path) ~= nil then
return true
end
return false
end
--- Initializes and starts a |uv_fs_event_t|
---
--- @param path string The path to watch
--- @param opts vim._watch.watch.Opts? 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', false },
opts = { opts, 'table', true },
callback = { callback, 'function', false },
})
opts = opts or {}
path = vim.fs.normalize(path)
local uvflags = opts and opts.uvflags or {}
local handle = assert(uv.new_fs_event())
local _, start_err, start_errname = handle:start(path, uvflags, function(err, filename, events)
assert(not err, err)
local fullpath = path
if filename then
fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename))
end
if skip(fullpath, opts) then
return
end
--- @type vim._watch.FileChangeType
local change_type
if events.rename then
local _, staterr, staterrname = uv.fs_stat(fullpath)
if staterrname == 'ENOENT' then
change_type = M.FileChangeType.Deleted
else
assert(not staterr, staterr)
change_type = M.FileChangeType.Created
end
elseif events.change then
change_type = M.FileChangeType.Changed
end
callback(fullpath, change_type)
end)
if start_err then
if start_errname == 'ENOENT' then
-- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path.
-- This is mostly a placeholder until we have `nvim_log` API.
vim.notify_once(('watch.watch: %s'):format(start_err), vim.log.levels.INFO)
end
-- TODO(justinmk): log important errors once we have `nvim_log` API.
return function() end
end
return function()
local _, stop_err = handle:stop()
assert(not stop_err, stop_err)
local is_closing, close_err = handle:is_closing()
assert(not close_err, close_err)
if not is_closing then
handle:close()
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 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', false },
opts = { opts, 'table', true },
callback = { callback, 'function', false },
})
opts = opts or {}
local debounce = opts.debounce or 500
---@type table<string, uv.uv_fs_event_t> handle by fullpath
local handles = {}
local timer = assert(uv.new_timer())
--- Map of file path to boolean indicating if the file has been changed
--- at some point within the debounce cycle.
--- @type table<string, boolean>
local filechanges = {}
local process_changes --- @type fun()
--- @param filepath string
--- @return uv.fs_event_start.callback
local function create_on_change(filepath)
return function(err, filename, events)
assert(not err, err)
local fullpath = vim.fs.joinpath(filepath, filename)
if skip(fullpath, opts) then
return
end
if not filechanges[fullpath] then
filechanges[fullpath] = events.change or false
end
timer:start(debounce, 0, process_changes)
end
end
process_changes = 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 fullpath, changed in pairs(filechanges) do
uv.fs_stat(fullpath, function(_, stat)
---@type vim._watch.FileChangeType
local change_type
if stat then
change_type = changed and M.FileChangeType.Changed or M.FileChangeType.Created
if stat.type == 'directory' then
local handle = handles[fullpath]
if not handle then
handle = assert(uv.new_fs_event())
handles[fullpath] = handle
handle:start(fullpath, {}, create_on_change(fullpath))
end
end
else
change_type = M.FileChangeType.Deleted
local handle = handles[fullpath]
if handle then
if not handle:is_closing() then
handle:close()
end
handles[fullpath] = nil
end
end
callback(fullpath, change_type)
end)
end
filechanges = {}
end
local root_handle = assert(uv.new_fs_event())
handles[path] = root_handle
local _, start_err, start_errname = root_handle:start(path, {}, create_on_change(path))
if start_err then
if start_errname == 'ENOENT' then
-- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path.
-- This is mostly a placeholder until we have `nvim_log` API.
vim.notify_once(('watch.watchdirs: %s'):format(start_err), vim.log.levels.INFO)
end
-- TODO(justinmk): log important errors once we have `nvim_log` API.
-- Continue. vim.fs.dir() will return nothing, so the code below is harmless.
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 }) do
if type == 'directory' then
local filepath = vim.fs.joinpath(path, name)
if not skip(filepath, opts) then
local handle = assert(uv.new_fs_event())
handles[filepath] = handle
handle:start(filepath, {}, create_on_change(filepath))
end
end
end
local function cancel()
for fullpath, handle in pairs(handles) do
if not handle:is_closing() then
handle:close()
end
handles[fullpath] = nil
end
timer:stop()
timer:close()
end
return cancel
end
--- @param data string
--- @param opts vim._watch.Opts?
--- @param callback vim._watch.Callback
local function on_inotifywait_output(data, opts, callback)
local d = vim.split(data, '%s+')
-- only consider the last reported event
local path, event, file = d[1], d[2], d[#d]
local fullpath = vim.fs.joinpath(path, file)
if skip(fullpath, opts) then
return
end
--- @type integer
local change_type
if event == 'CREATE' then
change_type = M.FileChangeType.Created
elseif event == 'DELETE' then
change_type = M.FileChangeType.Deleted
elseif event == 'MODIFY' then
change_type = M.FileChangeType.Changed
elseif event == 'MOVED_FROM' then
change_type = M.FileChangeType.Deleted
elseif event == 'MOVED_TO' then
change_type = M.FileChangeType.Created
end
if change_type then
callback(fullpath, change_type)
end
end
--- @param path string The path to watch. Must refer to a directory.
--- @param opts vim._watch.Opts?
--- @param callback vim._watch.Callback Callback for new events
--- @return fun() cancel Stops the watcher
function M.inotify(path, opts, callback)
local obj = vim.system({
'inotifywait',
'--quiet', -- suppress startup messages
'--no-dereference', -- don't follow symlinks
'--monitor', -- keep listening for events forever
'--recursive',
'--event',
'create',
'--event',
'delete',
'--event',
'modify',
'--event',
'move',
string.format('@%s/.git', path), -- ignore git directory
path,
}, {
stderr = function(err, data)
if err then
error(err)
end
if data and #vim.trim(data) > 0 then
vim.schedule(function()
if vim.fn.has('linux') == 1 and vim.startswith(data, 'Failed to watch') then
data = 'inotify(7) limit reached, see :h inotify-limitations for more info.'
end
vim.notify('inotify: ' .. data, vim.log.levels.ERROR)
end)
end
end,
stdout = function(err, data)
if err then
error(err)
end
for line in vim.gsplit(data or '', '\n', { plain = true, trimempty = true }) do
on_inotifywait_output(line, opts, callback)
end
end,
-- --latency is locale dependent but tostring() isn't and will always have '.' as decimal point.
env = { LC_NUMERIC = 'C' },
})
return function()
obj:kill(2)
end
end
return M