Merge pull request #27347 from lewis6991/fswatch

feat(lsp): add fswatch watchfunc backend
This commit is contained in:
Lewis Russell 2024-03-01 23:31:20 +00:00 committed by GitHub
commit 39928a7f24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 485 additions and 384 deletions

View File

@ -30,12 +30,12 @@ if [[ $os == Linux ]]; then
fi fi
if [[ -n $TEST ]]; then if [[ -n $TEST ]]; then
sudo apt-get install -y locales-all cpanminus attr libattr1-dev gdb sudo apt-get install -y locales-all cpanminus attr libattr1-dev gdb fswatch
fi fi
elif [[ $os == Darwin ]]; then elif [[ $os == Darwin ]]; then
brew update --quiet brew update --quiet
brew install ninja brew install ninja
if [[ -n $TEST ]]; then if [[ -n $TEST ]]; then
brew install cpanminus brew install cpanminus fswatch
fi fi
fi fi

View File

@ -369,6 +369,9 @@ The following changes to existing APIs or features add new behavior.
• The `workspace/didChangeWatchedFiles` LSP client capability is now enabled • The `workspace/didChangeWatchedFiles` LSP client capability is now enabled
by default. by default.
• On Mac or Windows, `libuv.fs_watch` is used as the backend.
• On Linux, `fswatch` (recommended) is used as the backend if available,
otherwise `libuv.fs_event` is used on each subdirectory.
• |LspRequest| autocmd callbacks now contain additional information about the LSP • |LspRequest| autocmd callbacks now contain additional information about the LSP
request status update that occurred. request status update that occurred.

View File

@ -1,45 +1,61 @@
local M = {}
local uv = vim.uv local uv = vim.uv
---@enum vim._watch.FileChangeType local M = {}
local FileChangeType = {
--- @enum vim._watch.FileChangeType
--- Types of events watchers will emit.
M.FileChangeType = {
Created = 1, Created = 1,
Changed = 2, Changed = 2,
Deleted = 3, Deleted = 3,
} }
--- Enumeration describing the types of events watchers will emit. --- @class vim._watch.Opts
M.FileChangeType = vim.tbl_add_reverse_lookup(FileChangeType)
--- Joins filepath elements by static '/' separator
--- ---
---@param ... (string) The path elements. --- @field debounce? integer ms
---@return string
local function filepath_join(...)
return table.concat({ ... }, '/')
end
--- Stops and closes a libuv |uv_fs_event_t| or |uv_fs_poll_t| handle
--- ---
---@param handle (uv.uv_fs_event_t|uv.uv_fs_poll_t) The handle to stop --- An |lpeg| pattern. Only changes to files whose full paths match the pattern
local function stop(handle) --- will be reported. Only matches against non-directoriess, all directories will
local _, stop_err = handle:stop() --- be watched for new potentially-matching files. exclude_pattern can be used to
assert(not stop_err, stop_err) --- filter out directories. When nil, matches any file name.
local is_closing, close_err = handle:is_closing() --- @field include_pattern? vim.lpeg.Pattern
assert(not close_err, close_err) ---
if not is_closing then --- An |lpeg| pattern. Only changes to files and directories whose full path does
handle:close() --- 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
--- @param path string
--- @param opts? vim._watch.Opts
local function skip(path, opts)
if not opts then
return false
end 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 end
--- Initializes and starts a |uv_fs_event_t| --- Initializes and starts a |uv_fs_event_t|
--- ---
---@param path (string) The path to watch --- @param path string The path to watch
---@param opts (table|nil) Additional options --- @param opts vim._watch.watch.Opts? Additional options:
--- - uvflags (table|nil) --- - uvflags (table|nil)
--- Same flags as accepted by |uv.fs_event_start()| --- Same flags as accepted by |uv.fs_event_start()|
---@param callback (function) The function called when new events --- @param callback vim._watch.Callback Callback for new events
---@return (function) Stops the watcher --- @return fun() cancel Stops the watcher
function M.watch(path, opts, callback) function M.watch(path, opts, callback)
vim.validate({ vim.validate({
path = { path, 'string', false }, path = { path, 'string', false },
@ -47,111 +63,120 @@ function M.watch(path, opts, callback)
callback = { callback, 'function', false }, callback = { callback, 'function', false },
}) })
opts = opts or {}
path = vim.fs.normalize(path) path = vim.fs.normalize(path)
local uvflags = opts and opts.uvflags or {} local uvflags = opts and opts.uvflags or {}
local handle, new_err = vim.uv.new_fs_event() local handle = assert(uv.new_fs_event())
assert(not new_err, new_err)
handle = assert(handle)
local _, start_err = handle:start(path, uvflags, function(err, filename, events) local _, start_err = handle:start(path, uvflags, function(err, filename, events)
assert(not err, err) assert(not err, err)
local fullpath = path local fullpath = path
if filename then if filename then
filename = filename:gsub('\\', '/') fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename))
fullpath = filepath_join(fullpath, filename)
end end
local change_type = events.change and M.FileChangeType.Changed or 0
if skip(fullpath, opts) then
return
end
--- @type vim._watch.FileChangeType
local change_type
if events.rename then if events.rename then
local _, staterr, staterrname = vim.uv.fs_stat(fullpath) local _, staterr, staterrname = uv.fs_stat(fullpath)
if staterrname == 'ENOENT' then if staterrname == 'ENOENT' then
change_type = M.FileChangeType.Deleted change_type = M.FileChangeType.Deleted
else else
assert(not staterr, staterr) assert(not staterr, staterr)
change_type = M.FileChangeType.Created change_type = M.FileChangeType.Created
end end
elseif events.change then
change_type = M.FileChangeType.Changed
end end
callback(fullpath, change_type) callback(fullpath, change_type)
end) end)
assert(not start_err, start_err) assert(not start_err, start_err)
return function() return function()
stop(handle) 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
end end
--- @class watch.PollOpts --- Initializes and starts a |uv_fs_event_t| recursively watching every directory underneath the
--- @field debounce? integer --- directory at path.
--- @field include_pattern? vim.lpeg.Pattern ---
--- @field exclude_pattern? vim.lpeg.Pattern --- @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 },
})
---@param path string
---@param opts watch.PollOpts
---@param callback function Called on new events
---@return function cancel stops the watcher
local function recurse_watch(path, opts, callback)
opts = opts or {} opts = opts or {}
local debounce = opts.debounce or 500 local debounce = opts.debounce or 500
local uvflags = {}
---@type table<string, uv.uv_fs_event_t> handle by fullpath ---@type table<string, uv.uv_fs_event_t> handle by fullpath
local handles = {} local handles = {}
local timer = assert(uv.new_timer()) local timer = assert(uv.new_timer())
---@type table[] --- Map of file path to boolean indicating if the file has been changed
local changesets = {} --- at some point within the debounce cycle.
--- @type table<string, boolean>
local filechanges = {}
local function is_included(filepath) local process_changes --- @type fun()
return opts.include_pattern and opts.include_pattern:match(filepath)
end
local function is_excluded(filepath)
return opts.exclude_pattern and opts.exclude_pattern:match(filepath)
end
local process_changes = function()
assert(false, "Replaced later. I'm only here as forward reference")
end
--- @param filepath string
--- @return uv.fs_event_start.callback
local function create_on_change(filepath) local function create_on_change(filepath)
return function(err, filename, events) return function(err, filename, events)
assert(not err, err) assert(not err, err)
local fullpath = vim.fs.joinpath(filepath, filename) local fullpath = vim.fs.joinpath(filepath, filename)
if is_included(fullpath) and not is_excluded(filepath) then if skip(fullpath, opts) then
table.insert(changesets, { return
fullpath = fullpath,
events = events,
})
timer:start(debounce, 0, process_changes)
end end
if not filechanges[fullpath] then
filechanges[fullpath] = events.change or false
end
timer:start(debounce, 0, process_changes)
end end
end end
process_changes = function() process_changes = function()
---@type table<string, table[]> -- Since the callback is debounced it may have also been deleted later on
local filechanges = vim.defaulttable() -- so we always need to check the existence of the file:
for i, change in ipairs(changesets) do -- stat succeeds, changed=true -> Changed
changesets[i] = nil -- stat succeeds, changed=false -> Created
if is_included(change.fullpath) and not is_excluded(change.fullpath) then -- stat fails -> Removed
table.insert(filechanges[change.fullpath], change.events) for fullpath, changed in pairs(filechanges) do
end
end
for fullpath, events_list in pairs(filechanges) do
uv.fs_stat(fullpath, function(_, stat) uv.fs_stat(fullpath, function(_, stat)
---@type vim._watch.FileChangeType ---@type vim._watch.FileChangeType
local change_type local change_type
if stat then if stat then
change_type = FileChangeType.Created change_type = changed and M.FileChangeType.Changed or M.FileChangeType.Created
for _, event in ipairs(events_list) do
if event.change then
change_type = FileChangeType.Changed
end
end
if stat.type == 'directory' then if stat.type == 'directory' then
local handle = handles[fullpath] local handle = handles[fullpath]
if not handle then if not handle then
handle = assert(uv.new_fs_event()) handle = assert(uv.new_fs_event())
handles[fullpath] = handle handles[fullpath] = handle
handle:start(fullpath, uvflags, create_on_change(fullpath)) handle:start(fullpath, {}, create_on_change(fullpath))
end end
end end
else else
change_type = M.FileChangeType.Deleted
local handle = handles[fullpath] local handle = handles[fullpath]
if handle then if handle then
if not handle:is_closing() then if not handle:is_closing() then
@ -159,15 +184,16 @@ local function recurse_watch(path, opts, callback)
end end
handles[fullpath] = nil handles[fullpath] = nil
end end
change_type = FileChangeType.Deleted
end end
callback(fullpath, change_type) callback(fullpath, change_type)
end) end)
end end
filechanges = {}
end end
local root_handle = assert(uv.new_fs_event()) local root_handle = assert(uv.new_fs_event())
handles[path] = root_handle handles[path] = root_handle
root_handle:start(path, uvflags, create_on_change(path)) root_handle:start(path, {}, create_on_change(path))
--- "640K ought to be enough for anyone" --- "640K ought to be enough for anyone"
--- Who has folders this deep? --- Who has folders this deep?
@ -175,12 +201,13 @@ local function recurse_watch(path, opts, callback)
for name, type in vim.fs.dir(path, { depth = max_depth }) do for name, type in vim.fs.dir(path, { depth = max_depth }) do
local filepath = vim.fs.joinpath(path, name) local filepath = vim.fs.joinpath(path, name)
if type == 'directory' and not is_excluded(filepath) then if type == 'directory' and not skip(filepath, opts) then
local handle = assert(uv.new_fs_event()) local handle = assert(uv.new_fs_event())
handles[filepath] = handle handles[filepath] = handle
handle:start(filepath, uvflags, create_on_change(filepath)) handle:start(filepath, {}, create_on_change(filepath))
end end
end end
local function cancel() local function cancel()
for fullpath, handle in pairs(handles) do for fullpath, handle in pairs(handles) do
if not handle:is_closing() then if not handle:is_closing() then
@ -191,34 +218,85 @@ local function recurse_watch(path, opts, callback)
timer:stop() timer:stop()
timer:close() timer:close()
end end
return cancel return cancel
end end
--- Initializes and starts a |uv_fs_poll_t| recursively watching every file underneath the --- @param data string
--- directory at path. --- @param opts vim._watch.Opts?
--- --- @param callback vim._watch.Callback
---@param path (string) The path to watch. Must refer to a directory. local function fswatch_output_handler(data, opts, callback)
---@param opts (table|nil) Additional options local d = vim.split(data, '%s+')
--- - debounce (number|nil)
--- Time events are debounced in ms. Defaults to 500 -- only consider the last reported event
--- - include_pattern (LPeg pattern|nil) local fullpath, event = d[1], d[#d]
--- An |lpeg| pattern. Only changes to files whose full paths match the pattern
--- will be reported. Only matches against non-directoriess, all directories will if skip(fullpath, opts) then
--- be watched for new potentially-matching files. exclude_pattern can be used to return
--- filter out directories. When nil, matches any file name. end
--- - exclude_pattern (LPeg pattern|nil)
--- An |lpeg| pattern. Only changes to files and directories whose full path does --- @type integer
--- not match the pattern will be reported. Matches against both files and local change_type
--- directories. When nil, matches nothing.
---@param callback (function) The function called when new events if event == 'Created' then
---@return function Stops the watcher change_type = M.FileChangeType.Created
function M.poll(path, opts, callback) elseif event == 'Removed' then
vim.validate({ change_type = M.FileChangeType.Deleted
path = { path, 'string', false }, elseif event == 'Updated' then
opts = { opts, 'table', true }, change_type = M.FileChangeType.Changed
callback = { callback, 'function', false }, elseif event == 'Renamed' 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
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.fswatch(path, opts, callback)
-- debounce isn't the same as latency but close enough
local latency = 0.5 -- seconds
if opts and opts.debounce then
latency = opts.debounce / 1000
end
local obj = vim.system({
'fswatch',
'--event=Created',
'--event=Removed',
'--event=Updated',
'--event=Renamed',
'--event-flags',
'--recursive',
'--latency=' .. tostring(latency),
'--exclude',
'/.git/',
path,
}, {
stdout = function(err, data)
if err then
error(err)
end
for line in vim.gsplit(data or '', '\n', { plain = true, trimempty = true }) do
fswatch_output_handler(line, opts, callback)
end
end,
}) })
return recurse_watch(path, opts, callback)
return function()
obj:kill(2)
end
end end
return M return M

View File

@ -7,7 +7,13 @@ local lpeg = vim.lpeg
local M = {} local M = {}
M._watchfunc = (vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1) and watch.watch or watch.poll if vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1 then
M._watchfunc = watch.watch
elseif vim.fn.executable('fswatch') == 1 then
M._watchfunc = watch.fswatch
else
M._watchfunc = watch.watchdirs
end
---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function ---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function
local cancels = vim.defaulttable() local cancels = vim.defaulttable()
@ -163,4 +169,13 @@ function M.unregister(unreg, ctx)
end end
end end
--- @param client_id integer
function M.cancel(client_id)
for _, reg_cancels in pairs(cancels[client_id]) do
for _, cancel in pairs(reg_cancels) do
cancel()
end
end
end
return M return M

View File

@ -815,6 +815,7 @@ function Client:_stop(force)
rpc.terminate() rpc.terminate()
self._graceful_shutdown_failed = true self._graceful_shutdown_failed = true
end end
vim.lsp._watchfiles.cancel(self.id)
end) end)
end end

View File

@ -1,10 +1,9 @@
local M = {} local M = {}
--- Performs a healthcheck for LSP local report_info = vim.health.info
function M.check() local report_warn = vim.health.warn
local report_info = vim.health.info
local report_warn = vim.health.warn
local function check_log()
local log = vim.lsp.log local log = vim.lsp.log
local current_log_level = log.get_level() local current_log_level = log.get_level()
local log_level_string = log.levels[current_log_level] ---@type string local log_level_string = log.levels[current_log_level] ---@type string
@ -27,9 +26,11 @@ function M.check()
local report_fn = (log_size / 1000000 > 100 and report_warn or report_info) local report_fn = (log_size / 1000000 > 100 and report_warn or report_info)
report_fn(string.format('Log size: %d KB', log_size / 1000)) report_fn(string.format('Log size: %d KB', log_size / 1000))
end
local clients = vim.lsp.get_clients() local function check_active_clients()
vim.health.start('vim.lsp: Active Clients') vim.health.start('vim.lsp: Active Clients')
local clients = vim.lsp.get_clients()
if next(clients) then if next(clients) then
for _, client in pairs(clients) do for _, client in pairs(clients) do
local attached_to = table.concat(vim.tbl_keys(client.attached_buffers or {}), ',') local attached_to = table.concat(vim.tbl_keys(client.attached_buffers or {}), ',')
@ -48,4 +49,33 @@ function M.check()
end end
end end
local function check_watcher()
vim.health.start('vim.lsp: File watcher')
local watchfunc = vim.lsp._watchfiles._watchfunc
assert(watchfunc)
local watchfunc_name --- @type string
if watchfunc == vim._watch.watch then
watchfunc_name = 'libuv-watch'
elseif watchfunc == vim._watch.watchdirs then
watchfunc_name = 'libuv-watchdirs'
elseif watchfunc == vim._watch.fswatch then
watchfunc_name = 'fswatch'
else
local nm = debug.getinfo(watchfunc, 'S').source
watchfunc_name = string.format('Custom (%s)', nm)
end
report_info('File watch backend: ' .. watchfunc_name)
if watchfunc_name == 'libuv-watchdirs' then
report_warn('libuv-watchdirs has known performance issues. Consider installing fswatch.')
end
end
--- Performs a healthcheck for LSP
function M.check()
check_log()
check_active_clients()
check_watcher()
end
return M return M

View File

@ -2,72 +2,113 @@ local helpers = require('test.functional.helpers')(after_each)
local eq = helpers.eq local eq = helpers.eq
local exec_lua = helpers.exec_lua local exec_lua = helpers.exec_lua
local clear = helpers.clear local clear = helpers.clear
local is_ci = helpers.is_ci
local is_os = helpers.is_os local is_os = helpers.is_os
local skip = helpers.skip local skip = helpers.skip
-- Create a file via a rename to avoid multiple
-- events which can happen with some backends on some platforms
local function touch(path)
local tmp = helpers.tmpname()
io.open(tmp, 'w'):close()
assert(vim.uv.fs_rename(tmp, path))
end
describe('vim._watch', function() describe('vim._watch', function()
before_each(function() before_each(function()
clear() clear()
end) end)
describe('watch', function() local function run(watchfunc)
it('detects file changes', function() it('detects file changes (watchfunc=' .. watchfunc .. '())', function()
skip(is_os('bsd'), 'Stopped working on bsd after 3ca967387c49c754561c3b11a574797504d40f38') if watchfunc == 'fswatch' then
skip(is_os('mac'), 'flaky test on mac')
skip(
not is_ci() and helpers.fn.executable('fswatch') == 0,
'fswatch not installed and not on CI'
)
skip(is_os('win'), 'not supported on windows')
end
if watchfunc == 'watch' then
skip(is_os('bsd'), 'Stopped working on bsd after 3ca967387c49c754561c3b11a574797504d40f38')
else
skip(
is_os('bsd'),
'kqueue only reports events on watched folder itself, not contained files #26110'
)
end
local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(helpers.tmpname()) .. '/nvim_XXXXXXXXXX') local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(helpers.tmpname()) .. '/nvim_XXXXXXXXXX')
local result = exec_lua( local expected_events = 0
local function wait_for_event()
expected_events = expected_events + 1
exec_lua(
[[
local expected_events = ...
assert(
vim.wait(3000, function()
return #_G.events == expected_events
end),
string.format(
'Timed out waiting for expected event no. %d. Current events seen so far: %s',
expected_events,
vim.inspect(events)
)
)
]],
expected_events
)
end
local unwatched_path = root_dir .. '/file.unwatched'
local watched_path = root_dir .. '/file'
exec_lua(
[[ [[
local root_dir = ... local root_dir, watchfunc = ...
local events = {} _G.events = {}
local expected_events = 0 _G.stop_watch = vim._watch[watchfunc](root_dir, {
local function wait_for_events() debounce = 100,
assert(vim.wait(100, function() return #events == expected_events end), 'Timed out waiting for expected number of events. Current events seen so far: ' .. vim.inspect(events)) include_pattern = vim.lpeg.P(root_dir) * vim.lpeg.P("/file") ^ -1,
end exclude_pattern = vim.lpeg.P(root_dir .. '/file.unwatched'),
}, function(path, change_type)
local stop = vim._watch.watch(root_dir, {}, function(path, change_type) table.insert(_G.events, { path = path, change_type = change_type })
table.insert(events, { path = path, change_type = change_type }) end)
end)
-- Only BSD seems to need some extra time for the watch to be ready to respond to events
if vim.fn.has('bsd') then
vim.wait(50)
end
local watched_path = root_dir .. '/file'
local watched, err = io.open(watched_path, 'w')
assert(not err, err)
expected_events = expected_events + 1
wait_for_events()
watched:close()
os.remove(watched_path)
expected_events = expected_events + 1
wait_for_events()
stop()
-- No events should come through anymore
local watched_path = root_dir .. '/file'
local watched, err = io.open(watched_path, 'w')
assert(not err, err)
vim.wait(50)
watched:close()
os.remove(watched_path)
vim.wait(50)
return events
]], ]],
root_dir root_dir,
watchfunc
) )
local expected = { if watchfunc ~= 'watch' then
vim.uv.sleep(200)
end
touch(watched_path)
touch(unwatched_path)
wait_for_event()
os.remove(watched_path)
os.remove(unwatched_path)
wait_for_event()
exec_lua [[_G.stop_watch()]]
-- No events should come through anymore
vim.uv.sleep(100)
touch(watched_path)
vim.uv.sleep(100)
os.remove(watched_path)
vim.uv.sleep(100)
eq({
{ {
change_type = exec_lua([[return vim._watch.FileChangeType.Created]]), change_type = exec_lua([[return vim._watch.FileChangeType.Created]]),
path = root_dir .. '/file', path = root_dir .. '/file',
@ -76,106 +117,11 @@ describe('vim._watch', function()
change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]), change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]),
path = root_dir .. '/file', path = root_dir .. '/file',
}, },
} }, exec_lua [[return _G.events]])
-- kqueue only reports events on the watched path itself, so creating a file within a
-- watched directory results in a "rename" libuv event on the directory.
if is_os('bsd') then
expected = {
{
change_type = exec_lua([[return vim._watch.FileChangeType.Created]]),
path = root_dir,
},
{
change_type = exec_lua([[return vim._watch.FileChangeType.Created]]),
path = root_dir,
},
}
end
eq(expected, result)
end) end)
end) end
describe('poll', function() run('watch')
it('detects file changes', function() run('watchdirs')
skip( run('fswatch')
is_os('bsd'),
'kqueue only reports events on watched folder itself, not contained files #26110'
)
local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(helpers.tmpname()) .. '/nvim_XXXXXXXXXX')
local result = exec_lua(
[[
local root_dir = ...
local lpeg = vim.lpeg
local events = {}
local debounce = 100
local wait_ms = debounce + 200
local expected_events = 0
local function wait_for_events()
assert(vim.wait(wait_ms, function() return #events == expected_events end), 'Timed out waiting for expected number of events. Current events seen so far: ' .. vim.inspect(events))
end
local incl = lpeg.P(root_dir) * lpeg.P("/file")^-1
local excl = lpeg.P(root_dir..'/file.unwatched')
local stop = vim._watch.poll(root_dir, {
debounce = debounce,
include_pattern = incl,
exclude_pattern = excl,
}, function(path, change_type)
table.insert(events, { path = path, change_type = change_type })
end)
local watched_path = root_dir .. '/file'
local watched, err = io.open(watched_path, 'w')
assert(not err, err)
local unwatched_path = root_dir .. '/file.unwatched'
local unwatched, err = io.open(unwatched_path, 'w')
assert(not err, err)
expected_events = expected_events + 1
wait_for_events()
watched:close()
os.remove(watched_path)
unwatched:close()
os.remove(unwatched_path)
expected_events = expected_events + 1
wait_for_events()
stop()
-- No events should come through anymore
local watched_path = root_dir .. '/file'
local watched, err = io.open(watched_path, 'w')
assert(not err, err)
watched:close()
os.remove(watched_path)
return events
]],
root_dir
)
local created = exec_lua([[return vim._watch.FileChangeType.Created]])
local deleted = exec_lua([[return vim._watch.FileChangeType.Deleted]])
local expected = {
{
change_type = created,
path = root_dir .. '/file',
},
{
change_type = deleted,
path = root_dir .. '/file',
},
}
eq(expected, result)
end)
end)
end) end)

View File

@ -205,8 +205,8 @@ describe('LSP', function()
client.stop() client.stop()
end, end,
on_exit = function(code, signal) on_exit = function(code, signal)
eq(0, code, 'exit code', fake_lsp_logfile) eq(0, code, 'exit code')
eq(0, signal, 'exit signal', fake_lsp_logfile) eq(0, signal, 'exit signal')
end, end,
settings = { settings = {
dummy = 1, dummy = 1,
@ -4490,113 +4490,140 @@ describe('LSP', function()
end) end)
describe('vim.lsp._watchfiles', function() describe('vim.lsp._watchfiles', function()
it('sends notifications when files change', function() local function test_filechanges(watchfunc)
skip( it(
is_os('bsd'), string.format('sends notifications when files change (watchfunc=%s)', watchfunc),
'kqueue only reports events on watched folder itself, not contained files #26110' function()
) if watchfunc == 'fswatch' then
local root_dir = tmpname() skip(
os.remove(root_dir) not is_ci() and fn.executable('fswatch') == 0,
mkdir(root_dir) 'fswatch not installed and not on CI'
)
skip(is_os('win'), 'not supported on windows')
skip(is_os('mac'), 'flaky')
end
exec_lua(create_server_definition) skip(
local result = exec_lua( is_os('bsd'),
[[ 'kqueue only reports events on watched folder itself, not contained files #26110'
local root_dir = ... )
local server = _create_server() local root_dir = tmpname()
local client_id = vim.lsp.start({ os.remove(root_dir)
name = 'watchfiles-test', mkdir(root_dir)
cmd = server.cmd,
root_dir = root_dir, exec_lua(create_server_definition)
capabilities = { local result = exec_lua(
workspace = { [[
didChangeWatchedFiles = { local root_dir, watchfunc = ...
dynamicRegistration = true,
local server = _create_server()
local client_id = vim.lsp.start({
name = 'watchfiles-test',
cmd = server.cmd,
root_dir = root_dir,
capabilities = {
workspace = {
didChangeWatchedFiles = {
dynamicRegistration = true,
},
}, },
}, },
}, })
})
local expected_messages = 2 -- initialize, initialized require('vim.lsp._watchfiles')._watchfunc = require('vim._watch')[watchfunc]
local watchfunc = require('vim.lsp._watchfiles')._watchfunc local expected_messages = 0
local msg_wait_timeout = watchfunc == vim._watch.poll and 2500 or 200
local function wait_for_messages()
assert(vim.wait(msg_wait_timeout, function() return #server.messages == expected_messages end), 'Timed out waiting for expected number of messages. Current messages seen so far: ' .. vim.inspect(server.messages))
end
wait_for_messages() local msg_wait_timeout = watchfunc == 'watch' and 200 or 2500
vim.lsp.handlers['client/registerCapability'](nil, { local function wait_for_message(incr)
registrations = { expected_messages = expected_messages + (incr or 1)
{ assert(
id = 'watchfiles-test-0', vim.wait(msg_wait_timeout, function()
method = 'workspace/didChangeWatchedFiles', return #server.messages == expected_messages
registerOptions = { end),
watchers = { 'Timed out waiting for expected number of messages. Current messages seen so far: '
{ .. vim.inspect(server.messages)
globPattern = '**/watch', )
kind = 7, end
wait_for_message(2) -- initialize, initialized
vim.lsp.handlers['client/registerCapability'](nil, {
registrations = {
{
id = 'watchfiles-test-0',
method = 'workspace/didChangeWatchedFiles',
registerOptions = {
watchers = {
{
globPattern = '**/watch',
kind = 7,
},
}, },
}, },
}, },
}, },
}, }, { client_id = client_id })
}, { client_id = client_id })
if watchfunc == vim._watch.poll then if watchfunc ~= 'watch' then
vim.wait(100) vim.wait(100)
end
local path = root_dir .. '/watch'
local tmp = vim.fn.tempname()
io.open(tmp, 'w'):close()
vim.uv.fs_rename(tmp, path)
wait_for_message()
os.remove(path)
wait_for_message()
vim.lsp.stop_client(client_id)
return server.messages
]],
root_dir,
watchfunc
)
local uri = vim.uri_from_fname(root_dir .. '/watch')
eq(6, #result)
eq({
method = 'workspace/didChangeWatchedFiles',
params = {
changes = {
{
type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]),
uri = uri,
},
},
},
}, result[3])
eq({
method = 'workspace/didChangeWatchedFiles',
params = {
changes = {
{
type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]),
uri = uri,
},
},
},
}, result[4])
end end
local path = root_dir .. '/watch'
local file = io.open(path, 'w')
file:close()
expected_messages = expected_messages + 1
wait_for_messages()
os.remove(path)
expected_messages = expected_messages + 1
wait_for_messages()
return server.messages
]],
root_dir
) )
end
local function watched_uri(fname) test_filechanges('watch')
return exec_lua( test_filechanges('watchdirs')
[[ test_filechanges('fswatch')
local root_dir, fname = ...
return vim.uri_from_fname(root_dir .. '/' .. fname)
]],
root_dir,
fname
)
end
eq(4, #result)
eq('workspace/didChangeWatchedFiles', result[3].method)
eq({
changes = {
{
type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]),
uri = watched_uri('watch'),
},
},
}, result[3].params)
eq('workspace/didChangeWatchedFiles', result[4].method)
eq({
changes = {
{
type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]),
uri = watched_uri('watch'),
},
},
}, result[4].params)
end)
it('correctly registers and unregisters', function() it('correctly registers and unregisters', function()
local root_dir = '/some_dir' local root_dir = '/some_dir'

View File

@ -372,6 +372,8 @@ function module.sysname()
return uv.os_uname().sysname:lower() return uv.os_uname().sysname:lower()
end end
--- @param s 'win'|'mac'|'freebsd'|'openbsd'|'bsd'
--- @return boolean
function module.is_os(s) function module.is_os(s)
if not (s == 'win' or s == 'mac' or s == 'freebsd' or s == 'openbsd' or s == 'bsd') then if not (s == 'win' or s == 'mac' or s == 'freebsd' or s == 'openbsd' or s == 'bsd') then
error('unknown platform: ' .. tostring(s)) error('unknown platform: ' .. tostring(s))
@ -396,33 +398,32 @@ local function tmpdir_is_local(dir)
return not not (dir and dir:find('Xtest')) return not not (dir and dir:find('Xtest'))
end end
local tmpname_id = 0
local tmpdir = tmpdir_get()
--- Creates a new temporary file for use by tests. --- Creates a new temporary file for use by tests.
module.tmpname = (function() function module.tmpname()
local seq = 0 if tmpdir_is_local(tmpdir) then
local tmpdir = tmpdir_get() -- Cannot control os.tmpname() dir, so hack our own tmpname() impl.
return function() tmpname_id = tmpname_id + 1
if tmpdir_is_local(tmpdir) then -- "…/Xtest_tmpdir/T42.7"
-- Cannot control os.tmpname() dir, so hack our own tmpname() impl. local fname = ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), tmpname_id)
seq = seq + 1 io.open(fname, 'w'):close()
-- "…/Xtest_tmpdir/T42.7" return fname
local fname = ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), seq)
io.open(fname, 'w'):close()
return fname
else
local fname = os.tmpname()
if module.is_os('win') and fname:sub(1, 2) == '\\s' then
-- In Windows tmpname() returns a filename starting with
-- special sequence \s, prepend $TEMP path
return tmpdir .. fname
elseif fname:match('^/tmp') and module.is_os('mac') then
-- In OS X /tmp links to /private/tmp
return '/private' .. fname
else
return fname
end
end
end end
end)()
local fname = os.tmpname()
if module.is_os('win') and fname:sub(1, 2) == '\\s' then
-- In Windows tmpname() returns a filename starting with
-- special sequence \s, prepend $TEMP path
return tmpdir .. fname
elseif module.is_os('mac') and fname:match('^/tmp') then
-- In OS X /tmp links to /private/tmp
return '/private' .. fname
end
return fname
end
local function deps_prefix() local function deps_prefix()
local env = os.getenv('DEPS_PREFIX') local env = os.getenv('DEPS_PREFIX')