diff options
author | Lewis Russell <lewis6991@gmail.com> | 2024-03-01 23:31:20 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-01 23:31:20 +0000 |
commit | 39928a7f24916b35577864e28e505dda74103fb8 (patch) | |
tree | 8887b5622dd5edae62a53748cb226a684e8b26b4 | |
parent | a5fe8f59d98398d04bed8586cee73864bbcdde92 (diff) | |
parent | 4ff3217bbd8747d2d44680a825ac29097faf9c4b (diff) | |
download | rneovim-39928a7f24916b35577864e28e505dda74103fb8.tar.gz rneovim-39928a7f24916b35577864e28e505dda74103fb8.tar.bz2 rneovim-39928a7f24916b35577864e28e505dda74103fb8.zip |
Merge pull request #27347 from lewis6991/fswatch
feat(lsp): add fswatch watchfunc backend
-rwxr-xr-x | .github/scripts/install_deps.sh | 4 | ||||
-rw-r--r-- | runtime/doc/news.txt | 3 | ||||
-rw-r--r-- | runtime/lua/vim/_watch.lua | 294 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/_watchfiles.lua | 17 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/client.lua | 1 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/health.lua | 40 | ||||
-rw-r--r-- | test/functional/lua/watch_spec.lua | 226 | ||||
-rw-r--r-- | test/functional/plugin/lsp_spec.lua | 205 | ||||
-rw-r--r-- | test/helpers.lua | 51 |
9 files changed, 471 insertions, 370 deletions
diff --git a/.github/scripts/install_deps.sh b/.github/scripts/install_deps.sh index 9a782e9698..ad81e053f9 100755 --- a/.github/scripts/install_deps.sh +++ b/.github/scripts/install_deps.sh @@ -30,12 +30,12 @@ if [[ $os == Linux ]]; then fi 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 elif [[ $os == Darwin ]]; then brew update --quiet brew install ninja if [[ -n $TEST ]]; then - brew install cpanminus + brew install cpanminus fswatch fi fi diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 50beb79adf..516ff6f0fe 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -369,6 +369,9 @@ The following changes to existing APIs or features add new behavior. • The `workspace/didChangeWatchedFiles` LSP client capability is now enabled 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 request status update that occurred. diff --git a/runtime/lua/vim/_watch.lua b/runtime/lua/vim/_watch.lua index 092781826f..d199cf8e29 100644 --- a/runtime/lua/vim/_watch.lua +++ b/runtime/lua/vim/_watch.lua @@ -1,45 +1,61 @@ -local M = {} local uv = vim.uv ----@enum vim._watch.FileChangeType -local FileChangeType = { +local M = {} + +--- @enum vim._watch.FileChangeType +--- Types of events watchers will emit. +M.FileChangeType = { Created = 1, Changed = 2, Deleted = 3, } ---- Enumeration describing the types of events watchers will emit. -M.FileChangeType = vim.tbl_add_reverse_lookup(FileChangeType) - ---- Joins filepath elements by static '/' separator +--- @class vim._watch.Opts --- ----@param ... (string) The path elements. ----@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 +--- @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 --- ----@param handle (uv.uv_fs_event_t|uv.uv_fs_poll_t) The handle to stop -local 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() +--- 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 + +--- @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 (table|nil) Additional options ---- - uvflags (table|nil) ---- Same flags as accepted by |uv.fs_event_start()| ----@param callback (function) The function called when new events ----@return (function) Stops the watcher +--- @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 }, @@ -47,111 +63,120 @@ function M.watch(path, opts, callback) callback = { callback, 'function', false }, }) + opts = opts or {} + path = vim.fs.normalize(path) local uvflags = opts and opts.uvflags or {} - local handle, new_err = vim.uv.new_fs_event() - assert(not new_err, new_err) - handle = assert(handle) + local handle = assert(uv.new_fs_event()) + local _, start_err = handle:start(path, uvflags, function(err, filename, events) assert(not err, err) local fullpath = path if filename then - filename = filename:gsub('\\', '/') - fullpath = filepath_join(fullpath, filename) + fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename)) + end + + if skip(fullpath, opts) then + return end - local change_type = events.change and M.FileChangeType.Changed or 0 + + --- @type vim._watch.FileChangeType + local change_type if events.rename then - local _, staterr, staterrname = vim.uv.fs_stat(fullpath) + 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) + assert(not start_err, start_err) + 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 ---- @class watch.PollOpts ---- @field debounce? integer ---- @field include_pattern? vim.lpeg.Pattern ---- @field exclude_pattern? vim.lpeg.Pattern +--- 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 }, + }) ----@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 {} local debounce = opts.debounce or 500 - local uvflags = {} + ---@type table<string, uv.uv_fs_event_t> handle by fullpath local handles = {} local timer = assert(uv.new_timer()) - ---@type table[] - local changesets = {} + --- 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 function is_included(filepath) - 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 + 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 is_included(fullpath) and not is_excluded(filepath) then - table.insert(changesets, { - fullpath = fullpath, - events = events, - }) - timer:start(debounce, 0, process_changes) + 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() - ---@type table<string, table[]> - local filechanges = vim.defaulttable() - for i, change in ipairs(changesets) do - changesets[i] = nil - if is_included(change.fullpath) and not is_excluded(change.fullpath) then - table.insert(filechanges[change.fullpath], change.events) - end - end - for fullpath, events_list in pairs(filechanges) do + -- 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 = FileChangeType.Created - for _, event in ipairs(events_list) do - if event.change then - change_type = FileChangeType.Changed - end - end + 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, uvflags, create_on_change(fullpath)) + 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 @@ -159,15 +184,16 @@ local function recurse_watch(path, opts, callback) end handles[fullpath] = nil end - change_type = FileChangeType.Deleted end callback(fullpath, change_type) end) end + filechanges = {} end + local root_handle = assert(uv.new_fs_event()) 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" --- 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 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()) handles[filepath] = handle - handle:start(filepath, uvflags, create_on_change(filepath)) + handle:start(filepath, {}, create_on_change(filepath)) end end + local function cancel() for fullpath, handle in pairs(handles) do if not handle:is_closing() then @@ -191,34 +218,85 @@ local function recurse_watch(path, opts, callback) timer:stop() timer:close() end + return cancel end ---- Initializes and starts a |uv_fs_poll_t| recursively watching every file underneath the ---- directory at path. ---- ----@param path (string) The path to watch. Must refer to a directory. ----@param opts (table|nil) Additional options ---- - debounce (number|nil) ---- Time events are debounced in ms. Defaults to 500 ---- - include_pattern (LPeg pattern|nil) ---- 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. ---- - exclude_pattern (LPeg pattern|nil) ---- 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. ----@param callback (function) The function called when new events ----@return function Stops the watcher -function M.poll(path, opts, callback) - vim.validate({ - path = { path, 'string', false }, - opts = { opts, 'table', true }, - callback = { callback, 'function', false }, +--- @param data string +--- @param opts vim._watch.Opts? +--- @param callback vim._watch.Callback +local function fswatch_output_handler(data, opts, callback) + local d = vim.split(data, '%s+') + + -- only consider the last reported event + local fullpath, event = d[1], d[#d] + + if skip(fullpath, opts) then + return + end + + --- @type integer + local change_type + + if event == 'Created' then + change_type = M.FileChangeType.Created + elseif event == 'Removed' then + change_type = M.FileChangeType.Deleted + elseif event == 'Updated' then + change_type = M.FileChangeType.Changed + 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 return M diff --git a/runtime/lua/vim/lsp/_watchfiles.lua b/runtime/lua/vim/lsp/_watchfiles.lua index 6ca60b78cd..49328fbe9b 100644 --- a/runtime/lua/vim/lsp/_watchfiles.lua +++ b/runtime/lua/vim/lsp/_watchfiles.lua @@ -7,7 +7,13 @@ local lpeg = vim.lpeg 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 local cancels = vim.defaulttable() @@ -163,4 +169,13 @@ function M.unregister(unreg, ctx) 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 diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index fcbaadbc57..dfb5137e2a 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -815,6 +815,7 @@ function Client:_stop(force) rpc.terminate() self._graceful_shutdown_failed = true end + vim.lsp._watchfiles.cancel(self.id) end) end diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index 15e4555b55..797a1097f9 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -1,10 +1,9 @@ local M = {} ---- Performs a healthcheck for LSP -function M.check() - local report_info = vim.health.info - 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 current_log_level = log.get_level() 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) 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') + local clients = vim.lsp.get_clients() if next(clients) then for _, client in pairs(clients) do local attached_to = table.concat(vim.tbl_keys(client.attached_buffers or {}), ',') @@ -48,4 +49,33 @@ function M.check() 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 diff --git a/test/functional/lua/watch_spec.lua b/test/functional/lua/watch_spec.lua index 044d707499..115fee8091 100644 --- a/test/functional/lua/watch_spec.lua +++ b/test/functional/lua/watch_spec.lua @@ -2,180 +2,126 @@ local helpers = require('test.functional.helpers')(after_each) local eq = helpers.eq local exec_lua = helpers.exec_lua local clear = helpers.clear +local is_ci = helpers.is_ci local is_os = helpers.is_os 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() before_each(function() clear() end) - describe('watch', function() - it('detects file changes', function() - skip(is_os('bsd'), 'Stopped working on bsd after 3ca967387c49c754561c3b11a574797504d40f38') - local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(helpers.tmpname()) .. '/nvim_XXXXXXXXXX') - - local result = exec_lua( - [[ - local root_dir = ... - - local events = {} - - local expected_events = 0 - local function wait_for_events() - 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)) - end - - local stop = vim._watch.watch(root_dir, {}, function(path, change_type) - table.insert(events, { path = path, change_type = change_type }) - 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 function run(watchfunc) + it('detects file changes (watchfunc=' .. watchfunc .. '())', function() + 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 - local watched_path = root_dir .. '/file' - local watched, err = io.open(watched_path, 'w') - assert(not err, err) + 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 - expected_events = expected_events + 1 - wait_for_events() + local root_dir = vim.uv.fs_mkdtemp(vim.fs.dirname(helpers.tmpname()) .. '/nvim_XXXXXXXXXX') - watched:close() - os.remove(watched_path) + local expected_events = 0 + local function wait_for_event() 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 - ) - - local expected = { - { - change_type = exec_lua([[return vim._watch.FileChangeType.Created]]), - path = root_dir .. '/file', - }, - { - change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]), - path = root_dir .. '/file', - }, - } - - -- 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, - }, - } + 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 - eq(expected, result) - end) - end) - - describe('poll', function() - it('detects file changes', function() - skip( - 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 unwatched_path = root_dir .. '/file.unwatched' + local watched_path = root_dir .. '/file' - local result = exec_lua( + exec_lua( [[ - local root_dir = ... - local lpeg = vim.lpeg - - local events = {} - - local debounce = 100 - local wait_ms = debounce + 200 + local root_dir, watchfunc = ... - 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 + _G.events = {} - 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, + _G.stop_watch = vim._watch[watchfunc](root_dir, { + debounce = 100, + include_pattern = vim.lpeg.P(root_dir) * vim.lpeg.P("/file") ^ -1, + exclude_pattern = vim.lpeg.P(root_dir .. '/file.unwatched'), }, function(path, change_type) - table.insert(events, { path = path, change_type = change_type }) - end) + table.insert(_G.events, { path = path, change_type = change_type }) + end) + ]], + root_dir, + watchfunc + ) - 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) + if watchfunc ~= 'watch' then + vim.uv.sleep(200) + end - expected_events = expected_events + 1 - wait_for_events() + touch(watched_path) + touch(unwatched_path) - watched:close() - os.remove(watched_path) - unwatched:close() - os.remove(unwatched_path) + wait_for_event() - expected_events = expected_events + 1 - wait_for_events() + os.remove(watched_path) + os.remove(unwatched_path) - stop() - -- No events should come through anymore + wait_for_event() - local watched_path = root_dir .. '/file' - local watched, err = io.open(watched_path, 'w') - assert(not err, err) + exec_lua [[_G.stop_watch()]] - watched:close() - os.remove(watched_path) + -- No events should come through anymore - return events - ]], - root_dir - ) + vim.uv.sleep(100) + touch(watched_path) + vim.uv.sleep(100) + os.remove(watched_path) + vim.uv.sleep(100) - local created = exec_lua([[return vim._watch.FileChangeType.Created]]) - local deleted = exec_lua([[return vim._watch.FileChangeType.Deleted]]) - local expected = { + eq({ { - change_type = created, + change_type = exec_lua([[return vim._watch.FileChangeType.Created]]), path = root_dir .. '/file', }, { - change_type = deleted, + change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]), path = root_dir .. '/file', }, - } - eq(expected, result) + }, exec_lua [[return _G.events]]) end) - end) + end + + run('watch') + run('watchdirs') + run('fswatch') end) diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index af31d9ccae..1e787d2b0c 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -205,8 +205,8 @@ describe('LSP', function() client.stop() end, on_exit = function(code, signal) - eq(0, code, 'exit code', fake_lsp_logfile) - eq(0, signal, 'exit signal', fake_lsp_logfile) + eq(0, code, 'exit code') + eq(0, signal, 'exit signal') end, settings = { dummy = 1, @@ -4490,113 +4490,140 @@ describe('LSP', function() end) describe('vim.lsp._watchfiles', function() - it('sends notifications when files change', function() - skip( - is_os('bsd'), - 'kqueue only reports events on watched folder itself, not contained files #26110' - ) - local root_dir = tmpname() - os.remove(root_dir) - mkdir(root_dir) + local function test_filechanges(watchfunc) + it( + string.format('sends notifications when files change (watchfunc=%s)', watchfunc), + function() + if watchfunc == 'fswatch' then + skip( + not is_ci() and fn.executable('fswatch') == 0, + '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) - local result = exec_lua( - [[ - local root_dir = ... + skip( + is_os('bsd'), + 'kqueue only reports events on watched folder itself, not contained files #26110' + ) - 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 root_dir = tmpname() + os.remove(root_dir) + mkdir(root_dir) + + exec_lua(create_server_definition) + local result = exec_lua( + [[ + local root_dir, watchfunc = ... + + 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 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 + local expected_messages = 0 - wait_for_messages() + local msg_wait_timeout = watchfunc == 'watch' and 200 or 2500 - vim.lsp.handlers['client/registerCapability'](nil, { - registrations = { - { - id = 'watchfiles-test-0', - method = 'workspace/didChangeWatchedFiles', - registerOptions = { - watchers = { - { - globPattern = '**/watch', - kind = 7, + local function wait_for_message(incr) + expected_messages = expected_messages + (incr or 1) + 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_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 - vim.wait(100) - end + if watchfunc ~= 'watch' then + vim.wait(100) + end - local path = root_dir .. '/watch' - local file = io.open(path, 'w') - file:close() + local path = root_dir .. '/watch' + local tmp = vim.fn.tempname() + io.open(tmp, 'w'):close() + vim.uv.fs_rename(tmp, path) - expected_messages = expected_messages + 1 - wait_for_messages() + wait_for_message() - os.remove(path) + os.remove(path) - expected_messages = expected_messages + 1 - wait_for_messages() + wait_for_message() - return server.messages - ]], - root_dir - ) + vim.lsp.stop_client(client_id) - local function watched_uri(fname) - return exec_lua( - [[ - local root_dir, fname = ... - return vim.uri_from_fname(root_dir .. '/' .. fname) - ]], - root_dir, - fname - ) - end + return server.messages + ]], + root_dir, + watchfunc + ) - 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) + 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 + + test_filechanges('watch') + test_filechanges('watchdirs') + test_filechanges('fswatch') it('correctly registers and unregisters', function() local root_dir = '/some_dir' diff --git a/test/helpers.lua b/test/helpers.lua index 24ccead8b2..3d53aa3be9 100644 --- a/test/helpers.lua +++ b/test/helpers.lua @@ -372,6 +372,8 @@ function module.sysname() return uv.os_uname().sysname:lower() end +--- @param s 'win'|'mac'|'freebsd'|'openbsd'|'bsd' +--- @return boolean function module.is_os(s) if not (s == 'win' or s == 'mac' or s == 'freebsd' or s == 'openbsd' or s == 'bsd') then error('unknown platform: ' .. tostring(s)) @@ -396,33 +398,32 @@ local function tmpdir_is_local(dir) return not not (dir and dir:find('Xtest')) end +local tmpname_id = 0 +local tmpdir = tmpdir_get() + --- Creates a new temporary file for use by tests. -module.tmpname = (function() - local seq = 0 - local tmpdir = tmpdir_get() - return function() - if tmpdir_is_local(tmpdir) then - -- Cannot control os.tmpname() dir, so hack our own tmpname() impl. - seq = seq + 1 - -- "…/Xtest_tmpdir/T42.7" - 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 +function module.tmpname() + if tmpdir_is_local(tmpdir) then + -- Cannot control os.tmpname() dir, so hack our own tmpname() impl. + tmpname_id = tmpname_id + 1 + -- "…/Xtest_tmpdir/T42.7" + local fname = ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), tmpname_id) + io.open(fname, 'w'):close() + return fname 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 env = os.getenv('DEPS_PREFIX') |