diff options
author | Jon Huhn <nojnhuh@users.noreply.github.com> | 2023-03-05 00:52:27 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-05 07:52:27 +0100 |
commit | ac69ba5fa0081026f2c5e6e29d5788802479b7b9 (patch) | |
tree | f78cce287bc020646410eaa57a9565647e2cba03 | |
parent | 419819b6245e120aba8897e3ddea711b2cd0246c (diff) | |
download | rneovim-ac69ba5fa0081026f2c5e6e29d5788802479b7b9.tar.gz rneovim-ac69ba5fa0081026f2c5e6e29d5788802479b7b9.tar.bz2 rneovim-ac69ba5fa0081026f2c5e6e29d5788802479b7b9.zip |
feat(lsp): implement workspace/didChangeWatchedFiles (#22405)
-rw-r--r-- | runtime/doc/news.txt | 5 | ||||
-rw-r--r-- | runtime/lua/vim/_editor.lua | 1 | ||||
-rw-r--r-- | runtime/lua/vim/_watch.lua | 174 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/_watchfiles.lua | 293 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 38 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 4 | ||||
-rw-r--r-- | test/functional/lua/watch_spec.lua | 195 | ||||
-rw-r--r-- | test/functional/plugin/lsp/watchfiles_spec.lua | 173 | ||||
-rw-r--r-- | test/functional/plugin/lsp_spec.lua | 421 |
9 files changed, 1295 insertions, 9 deletions
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index cda4792c9e..415195e27e 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -184,6 +184,11 @@ The following new APIs or features were added. • Vim's `has('gui_running')` is now supported as a way for plugins to check if a GUI (not the |TUI|) is attached to Nvim. |has()| +• Added preliminary support for the `workspace/didChangeWatchedFiles` capability + to the LSP client to notify servers of file changes on disk. The feature is + disabled by default and can be enabled by setting the + `workspace.didChangeWatchedFiles.dynamicRegistration=true` capability. + ============================================================================== CHANGED FEATURES *news-changes* diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua index ab49e26dde..c205451ff9 100644 --- a/runtime/lua/vim/_editor.lua +++ b/runtime/lua/vim/_editor.lua @@ -37,6 +37,7 @@ for k, v in pairs({ health = true, fs = true, secure = true, + _watch = true, }) do vim._submodules[k] = v end diff --git a/runtime/lua/vim/_watch.lua b/runtime/lua/vim/_watch.lua new file mode 100644 index 0000000000..dba1522ec8 --- /dev/null +++ b/runtime/lua/vim/_watch.lua @@ -0,0 +1,174 @@ +local M = {} + +--- Enumeration describing the types of events watchers will emit. +M.FileChangeType = vim.tbl_add_reverse_lookup({ + Created = 1, + Changed = 2, + Deleted = 3, +}) + +---@private +--- Joins filepath elements by static '/' separator +--- +---@param ... (string) The path elements. +local function filepath_join(...) + return table.concat({ ... }, '/') +end + +---@private +--- Stops and closes a libuv |uv_fs_event_t| or |uv_fs_poll_t| handle +--- +---@param handle (uv_fs_event_t|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() + end +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 +---@returns (function) A function to stop the watch +function M.watch(path, opts, callback) + vim.validate({ + path = { path, 'string', false }, + opts = { opts, 'table', true }, + callback = { callback, 'function', false }, + }) + + path = vim.fs.normalize(path) + local uvflags = opts and opts.uvflags or {} + local handle, new_err = vim.loop.new_fs_event() + assert(not new_err, new_err) + 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) + end + local change_type = events.change and M.FileChangeType.Changed or 0 + if events.rename then + local _, staterr, staterrname = vim.loop.fs_stat(fullpath) + if staterrname == 'ENOENT' then + change_type = M.FileChangeType.Deleted + else + assert(not staterr, staterr) + change_type = M.FileChangeType.Created + end + end + callback(fullpath, change_type) + end) + assert(not start_err, start_err) + return function() + stop(handle) + end +end + +local default_poll_interval_ms = 2000 + +---@private +--- Implementation for poll, hiding internally-used parameters. +--- +---@param watches (table|nil) A tree structure to maintain state for recursive watches. +--- - handle (uv_fs_poll_t) +--- The libuv handle +--- - cancel (function) +--- A function that cancels the handle and all children's handles +--- - is_dir (boolean) +--- Indicates whether the path is a directory (and the poll should +--- be invoked recursively) +--- - children (table|nil) +--- A mapping of directory entry name to its recursive watches +local function poll_internal(path, opts, callback, watches) + path = vim.fs.normalize(path) + local interval = opts and opts.interval or default_poll_interval_ms + watches = watches or { + is_dir = true, + } + + if not watches.handle then + local poll, new_err = vim.loop.new_fs_poll() + assert(not new_err, new_err) + watches.handle = poll + local _, start_err = poll:start( + path, + interval, + vim.schedule_wrap(function(err) + if err == 'ENOENT' then + return + end + assert(not err, err) + poll_internal(path, opts, callback, watches) + callback(path, M.FileChangeType.Changed) + end) + ) + assert(not start_err, start_err) + callback(path, M.FileChangeType.Created) + end + + watches.cancel = function() + if watches.children then + for _, w in pairs(watches.children) do + w.cancel() + end + end + stop(watches.handle) + end + + if watches.is_dir then + watches.children = watches.children or {} + local exists = {} + for name, ftype in vim.fs.dir(path) do + exists[name] = true + if not watches.children[name] then + watches.children[name] = { + is_dir = ftype == 'directory', + } + poll_internal(filepath_join(path, name), opts, callback, watches.children[name]) + end + end + + local newchildren = {} + for name, watch in pairs(watches.children) do + if exists[name] then + newchildren[name] = watch + else + watch.cancel() + watches.children[name] = nil + callback(path .. '/' .. name, M.FileChangeType.Deleted) + end + end + watches.children = newchildren + end + + return watches.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 +--- - interval (number|nil) +--- Polling interval in ms as passed to |uv.fs_poll_start()|. Defaults to 2000. +---@param callback (function) The function called when new events +---@returns (function) A function to stop the watch. +function M.poll(path, opts, callback) + vim.validate({ + path = { path, 'string', false }, + opts = { opts, 'table', true }, + callback = { callback, 'function', false }, + }) + return poll_internal(path, opts, callback, nil) +end + +return M diff --git a/runtime/lua/vim/lsp/_watchfiles.lua b/runtime/lua/vim/lsp/_watchfiles.lua new file mode 100644 index 0000000000..96d7fa1d35 --- /dev/null +++ b/runtime/lua/vim/lsp/_watchfiles.lua @@ -0,0 +1,293 @@ +local bit = require('bit') +local watch = require('vim._watch') +local protocol = require('vim.lsp.protocol') + +local M = {} + +---@private +---Parses the raw pattern into a number of Lua-native patterns. +--- +---@param pattern string The raw glob pattern +---@return table A list of Lua patterns. A match with any of them matches the input glob pattern. +local function parse(pattern) + local patterns = { '' } + + local path_sep = '[/\\]' + local non_path_sep = '[^/\\]' + + local function append(chunks) + local new_patterns = {} + for _, p in ipairs(patterns) do + for _, chunk in ipairs(chunks) do + table.insert(new_patterns, p .. chunk) + end + end + patterns = new_patterns + end + + local function split(s, sep) + local segments = {} + local segment = '' + local in_braces = false + local in_brackets = false + for i = 1, #s do + local c = string.sub(s, i, i) + if c == sep and not in_braces and not in_brackets then + table.insert(segments, segment) + segment = '' + else + if c == '{' then + in_braces = true + elseif c == '}' then + in_braces = false + elseif c == '[' then + in_brackets = true + elseif c == ']' then + in_brackets = false + end + segment = segment .. c + end + end + if segment ~= '' then + table.insert(segments, segment) + end + return segments + end + + local function escape(c) + if + c == '?' + or c == '.' + or c == '(' + or c == ')' + or c == '%' + or c == '[' + or c == ']' + or c == '*' + or c == '+' + or c == '-' + then + return '%' .. c + end + return c + end + + local segments = split(pattern, '/') + for i, segment in ipairs(segments) do + local last_seg = i == #segments + if segment == '**' then + local chunks = { + path_sep .. '-', + '.-' .. path_sep, + } + if last_seg then + chunks = { '.-' } + end + append(chunks) + else + local in_braces = false + local brace_val = '' + local in_brackets = false + local bracket_val = '' + for j = 1, #segment do + local char = string.sub(segment, j, j) + if char ~= '}' and in_braces then + brace_val = brace_val .. char + else + if in_brackets and (char ~= ']' or bracket_val == '') then + local res + if char == '-' then + res = char + elseif bracket_val == '' and char == '!' then + res = '^' + elseif char == '/' then + res = '' + else + res = escape(char) + end + bracket_val = bracket_val .. res + else + if char == '{' then + in_braces = true + elseif char == '[' then + in_brackets = true + elseif char == '}' then + local choices = split(brace_val, ',') + local parsed_choices = {} + for _, choice in ipairs(choices) do + table.insert(parsed_choices, parse(choice)) + end + append(vim.tbl_flatten(parsed_choices)) + in_braces = false + brace_val = '' + elseif char == ']' then + append({ '[' .. bracket_val .. ']' }) + in_brackets = false + bracket_val = '' + elseif char == '?' then + append({ non_path_sep }) + elseif char == '*' then + append({ non_path_sep .. '-' }) + else + append({ escape(char) }) + end + end + end + end + + if not last_seg and (segments[i + 1] ~= '**' or i + 1 < #segments) then + append({ path_sep }) + end + end + end + + return patterns +end + +---@private +--- Implementation of LSP 3.17.0's pattern matching: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pattern +--- Modeled after VSCode's implementation: https://github.com/microsoft/vscode/blob/0319eed971719ad48e9093daba9d65a5013ec5ab/src/vs/base/common/glob.ts#L509 +--- +---@param pattern string|table The glob pattern (raw or parsed) to match. +---@param s string The string to match against pattern. +---@return boolean Whether or not pattern matches s. +function M._match(pattern, s) + if type(pattern) == 'string' then + pattern = parse(pattern) + end + -- Since Lua's built-in string pattern matching does not have an alternate + -- operator like '|', `parse` will construct one pattern for each possible + -- alternative. Any pattern that matches thus matches the glob. + for _, p in ipairs(pattern) do + if s:match('^' .. p .. '$') then + return true + end + end + return false +end + +M._watchfunc = (vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1) and watch.watch or watch.poll + +---@type table<number, table<number, function()>> client id -> registration id -> cancel function +local cancels = vim.defaulttable() + +local queue_timeout_ms = 100 +---@type table<number, uv_timer_t> client id -> libuv timer which will send queued changes at its timeout +local queue_timers = {} +---@type table<number, lsp.FileEvent[]> client id -> set of queued changes to send in a single LSP notification +local change_queues = {} +---@type table<number, table<string, lsp.FileChangeType>> client id -> URI -> last type of change processed +--- Used to prune consecutive events of the same type for the same file +local change_cache = vim.defaulttable() + +local to_lsp_change_type = { + [watch.FileChangeType.Created] = protocol.FileChangeType.Created, + [watch.FileChangeType.Changed] = protocol.FileChangeType.Changed, + [watch.FileChangeType.Deleted] = protocol.FileChangeType.Deleted, +} + +--- Registers the workspace/didChangeWatchedFiles capability dynamically. +--- +---@param reg table LSP Registration object. +---@param ctx table Context from the |lsp-handler|. +function M.register(reg, ctx) + local client_id = ctx.client_id + local client = vim.lsp.get_client_by_id(client_id) + local watch_regs = {} + for _, w in ipairs(reg.registerOptions.watchers) do + local glob_patterns = {} + if type(w.globPattern) == 'string' then + for _, folder in ipairs(client.workspace_folders) do + table.insert(glob_patterns, { baseUri = folder.uri, pattern = w.globPattern }) + end + else + table.insert(glob_patterns, w.globPattern) + end + for _, glob_pattern in ipairs(glob_patterns) do + local pattern = parse(glob_pattern.pattern) + local base_dir = nil + if type(glob_pattern.baseUri) == 'string' then + base_dir = glob_pattern.baseUri + elseif type(glob_pattern.baseUri) == 'table' then + base_dir = glob_pattern.baseUri.uri + end + assert(base_dir, "couldn't identify root of watch") + base_dir = vim.uri_to_fname(base_dir) + local kind = w.kind + or protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete + + table.insert(watch_regs, { + base_dir = base_dir, + pattern = pattern, + kind = kind, + }) + end + end + + local callback = function(base_dir) + return function(fullpath, change_type) + for _, w in ipairs(watch_regs) do + change_type = to_lsp_change_type[change_type] + -- e.g. match kind with Delete bit (0b0100) to Delete change_type (3) + local kind_mask = bit.lshift(1, change_type - 1) + local change_type_match = bit.band(w.kind, kind_mask) == kind_mask + if base_dir == w.base_dir and M._match(w.pattern, fullpath) and change_type_match then + local change = { + uri = vim.uri_from_fname(fullpath), + type = change_type, + } + + local last_type = change_cache[client_id][change.uri] + if last_type ~= change.type then + change_queues[client_id] = change_queues[client_id] or {} + table.insert(change_queues[client_id], change) + change_cache[client_id][change.uri] = change.type + end + + if not queue_timers[client_id] then + queue_timers[client_id] = vim.defer_fn(function() + client.notify('workspace/didChangeWatchedFiles', { + changes = change_queues[client_id], + }) + queue_timers[client_id] = nil + change_queues[client_id] = nil + change_cache[client_id] = nil + end, queue_timeout_ms) + end + + break -- if an event matches multiple watchers, only send one notification + end + end + end + end + + local watching = {} + for _, w in ipairs(watch_regs) do + if not watching[w.base_dir] then + watching[w.base_dir] = true + table.insert( + cancels[client_id][reg.id], + M._watchfunc(w.base_dir, { uvflags = { recursive = true } }, callback(w.base_dir)) + ) + end + end +end + +--- Unregisters the workspace/didChangeWatchedFiles capability dynamically. +--- +---@param unreg table LSP Unregistration object. +---@param ctx table Context from the |lsp-handler|. +function M.unregister(unreg, ctx) + local client_id = ctx.client_id + local client_cancels = cancels[client_id] + local reg_cancels = client_cancels[unreg.id] + while #reg_cancels > 0 do + table.remove(reg_cancels)() + end + client_cancels[unreg.id] = nil + if not next(cancels[client_id]) then + cancels[client_id] = nil + end +end + +return M diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 5096100a60..ee5b63d260 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -117,15 +117,35 @@ M['window/showMessageRequest'] = function(_, result) end --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability -M['client/registerCapability'] = function(_, _, ctx) - local client_id = ctx.client_id - local warning_tpl = 'The language server %s triggers a registerCapability ' - .. 'handler despite dynamicRegistration set to false. ' - .. 'Report upstream, this warning is harmless' - local client = vim.lsp.get_client_by_id(client_id) - local client_name = client and client.name or string.format('id=%d', client_id) - local warning = string.format(warning_tpl, client_name) - log.warn(warning) +M['client/registerCapability'] = function(_, result, ctx) + local log_unsupported = false + for _, reg in ipairs(result.registrations) do + if reg.method == 'workspace/didChangeWatchedFiles' then + require('vim.lsp._watchfiles').register(reg, ctx) + else + log_unsupported = true + end + end + if log_unsupported then + local client_id = ctx.client_id + local warning_tpl = 'The language server %s triggers a registerCapability ' + .. 'handler despite dynamicRegistration set to false. ' + .. 'Report upstream, this warning is harmless' + local client = vim.lsp.get_client_by_id(client_id) + local client_name = client and client.name or string.format('id=%d', client_id) + local warning = string.format(warning_tpl, client_name) + log.warn(warning) + end + return vim.NIL +end + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_unregisterCapability +M['client/unregisterCapability'] = function(_, result, ctx) + for _, unreg in ipairs(result.unregisterations) do + if unreg.method == 'workspace/didChangeWatchedFiles' then + require('vim.lsp._watchfiles').unregister(unreg, ctx) + end + end return vim.NIL end diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 41dfc9e00e..27dd68645a 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -834,6 +834,10 @@ function protocol.make_client_capabilities() semanticTokens = { refreshSupport = true, }, + didChangeWatchedFiles = { + dynamicRegistration = false, + relativePatternSupport = true, + }, }, experimental = nil, window = { diff --git a/test/functional/lua/watch_spec.lua b/test/functional/lua/watch_spec.lua new file mode 100644 index 0000000000..19bb411ce6 --- /dev/null +++ b/test/functional/lua/watch_spec.lua @@ -0,0 +1,195 @@ +local helpers = require('test.functional.helpers')(after_each) +local eq = helpers.eq +local exec_lua = helpers.exec_lua +local clear = helpers.clear +local is_os = helpers.is_os +local lfs = require('lfs') + +describe('vim._watch', function() + before_each(function() + clear() + end) + + describe('watch', function() + it('detects file changes', function() + local root_dir = helpers.tmpname() + os.remove(root_dir) + lfs.mkdir(root_dir) + + 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 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 + ) + + 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, + }, + } + end + + eq(expected, result) + end) + end) + + describe('poll', function() + it('detects file changes', function() + local root_dir = helpers.tmpname() + os.remove(root_dir) + lfs.mkdir(root_dir) + + local result = exec_lua( + [[ + local root_dir = ... + + local events = {} + + local poll_interval_ms = 1000 + local poll_wait_ms = poll_interval_ms+200 + + local expected_events = 0 + local function wait_for_events() + assert(vim.wait(poll_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 stop = vim._watch.poll(root_dir, { interval = poll_interval_ms }, function(path, change_type) + table.insert(events, { path = path, change_type = change_type }) + end) + + -- polling generates Created events for the existing entries when it starts. + expected_events = expected_events + 1 + wait_for_events() + + local watched_path = root_dir .. '/file' + local watched, err = io.open(watched_path, 'w') + assert(not err, err) + + expected_events = expected_events + 2 + wait_for_events() + + watched:close() + os.remove(watched_path) + + expected_events = expected_events + 2 + 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(poll_wait_ms) + + watched:close() + os.remove(watched_path) + + return events + ]], + root_dir + ) + + eq(5, #result) + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Created]]), + path = root_dir, + }, result[1]) + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Created]]), + path = root_dir .. '/file', + }, result[2]) + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Changed]]), + path = root_dir, + }, result[3]) + -- The file delete and corresponding directory change events do not happen in any + -- particular order, so allow either + if result[4].path == root_dir then + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Changed]]), + path = root_dir, + }, result[4]) + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]), + path = root_dir .. '/file', + }, result[5]) + else + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Deleted]]), + path = root_dir .. '/file', + }, result[4]) + eq({ + change_type = exec_lua([[return vim._watch.FileChangeType.Changed]]), + path = root_dir, + }, result[5]) + end + end) + end) +end) diff --git a/test/functional/plugin/lsp/watchfiles_spec.lua b/test/functional/plugin/lsp/watchfiles_spec.lua new file mode 100644 index 0000000000..c5d6803a7f --- /dev/null +++ b/test/functional/plugin/lsp/watchfiles_spec.lua @@ -0,0 +1,173 @@ +local helpers = require('test.functional.helpers')(after_each) + +local eq = helpers.eq +local exec_lua = helpers.exec_lua +local has_err = require('luassert').has.errors + +describe('vim.lsp._watchfiles', function() + before_each(helpers.clear) + after_each(helpers.clear) + + local match = function(...) + return exec_lua('return require("vim.lsp._watchfiles")._match(...)', ...) + end + + describe('glob matching', function() + it('should match literal strings', function() + eq(true, match('', '')) + eq(false, match('', 'a')) + eq(true, match('a', 'a')) + eq(true, match('abc', 'abc')) + eq(false, match('abc', 'abcdef')) + eq(false, match('abc', 'a')) + eq(false, match('a', 'b')) + eq(false, match('.', 'a')) + eq(true, match('$', '$')) + eq(false, match('dir/subdir', 'dir/subdir/file')) + end) + + it('should match * wildcards', function() + -- eq(false, match('*', '')) -- TODO: this fails + eq(true, match('*', 'a')) + eq(false, match('*', '/a')) + eq(false, match('*', 'a/')) + eq(true, match('*', 'aaa')) + eq(true, match('*.txt', 'file.txt')) + eq(false, match('*.txt', 'file.txtxt')) + eq(false, match('*.txt', 'dir/file.txt')) + eq(false, match('*.txt', '/dir/file.txt')) + eq(false, match('*.txt', 'C:/dir/file.txt')) + eq(false, match('*.dir', 'test.dir/file')) + eq(true, match('file.*', 'file.txt')) + eq(false, match('file.*', 'not-file.txt')) + eq(false, match('dir/*.txt', 'file.txt')) + eq(true, match('dir/*.txt', 'dir/file.txt')) + eq(false, match('dir/*.txt', 'dir/subdir/file.txt')) + end) + + it('should match ? wildcards', function() + eq(false, match('?', '')) + eq(true, match('?', 'a')) + eq(false, match('??', 'a')) + eq(false, match('?', 'ab')) + eq(true, match('??', 'ab')) + eq(true, match('a?c', 'abc')) + eq(false, match('a?c', 'a/c')) + end) + + it('should match ** wildcards', function() + eq(true, match('**', '')) + eq(true, match('**', 'a')) + eq(true, match('**', 'a/')) + eq(true, match('**', '/a')) + eq(true, match('**', 'C:/a')) + eq(true, match('**', 'a/a')) + eq(true, match('**', 'a/a/a')) + eq(false, match('a**', '')) + eq(true, match('a**', 'a')) + eq(true, match('a**', 'abcd')) + eq(false, match('a**', 'ba')) + eq(false, match('a**', 'a/b')) + eq(false, match('**a', '')) + eq(true, match('**a', 'a')) + eq(true, match('**a', 'dcba')) + eq(false, match('**a', 'ab')) + eq(false, match('**a', 'b/a')) + eq(false, match('a/**', '')) + eq(true, match('a/**', 'a')) + eq(true, match('a/**', 'a/b')) + eq(false, match('a/**', 'b/a')) + eq(false, match('a/**', '/a')) + eq(false, match('**/a', '')) + eq(true, match('**/a', 'a')) + eq(false, match('**/a', 'a/b')) + eq(true, match('**/a', '/a')) + eq(false, match('a/**/c', 'a')) + eq(false, match('a/**/c', 'c')) + eq(true, match('a/**/c', 'a/c')) + eq(true, match('a/**/c', 'a/b/c')) + eq(true, match('a/**/c', 'a/b/b/c')) + eq(true, match('**/a/**', 'a')) + eq(true, match('**/a/**', '/dir/a')) + eq(true, match('**/a/**', 'a/dir')) + eq(true, match('**/a/**', 'dir/a/dir')) + eq(true, match('**/a/**', '/a/dir')) + eq(true, match('**/a/**', 'C:/a/dir')) + -- eq(false, match('**/a/**', 'a.txt')) -- TODO: this fails + end) + + it('should match {} groups', function() + eq(false, match('{}', '')) + eq(true, match('{,}', '')) + eq(false, match('{}', 'a')) + eq(true, match('{a}', 'a')) + eq(false, match('{a}', 'aa')) + eq(false, match('{a}', 'ab')) + eq(false, match('{ab}', 'a')) + eq(true, match('{ab}', 'ab')) + eq(true, match('{a,b}', 'a')) + eq(true, match('{a,b}', 'b')) + eq(false, match('{a,b}', 'ab')) + eq(true, match('{ab,cd}', 'ab')) + eq(false, match('{ab,cd}', 'a')) + eq(true, match('{ab,cd}', 'cd')) + eq(true, match('{a,b,c}', 'c')) + eq(false, match('{a,{b,c}}', 'c')) -- {} can't nest + end) + + it('should match [] groups', function() + eq(true, match('[]', '')) + eq(false, match('[a-z]', '')) + eq(true, match('[a-z]', 'a')) + eq(false, match('[a-z]', 'ab')) + eq(true, match('[a-z]', 'z')) + eq(true, match('[a-z]', 'j')) + eq(false, match('[a-f]', 'j')) + eq(false, match('[a-z]', '`')) -- 'a' - 1 + eq(false, match('[a-z]', '{')) -- 'z' + 1 + eq(false, match('[a-z]', 'A')) + eq(false, match('[a-z]', '5')) + eq(true, match('[A-Z]', 'A')) + eq(true, match('[A-Z]', 'Z')) + eq(true, match('[A-Z]', 'J')) + eq(false, match('[A-Z]', '@')) -- 'A' - 1 + eq(false, match('[A-Z]', '[')) -- 'Z' + 1 + eq(false, match('[A-Z]', 'a')) + eq(false, match('[A-Z]', '5')) + eq(true, match('[a-zA-Z0-9]', 'z')) + eq(true, match('[a-zA-Z0-9]', 'Z')) + eq(true, match('[a-zA-Z0-9]', '9')) + eq(false, match('[a-zA-Z0-9]', '&')) + end) + + it('should match [!...] groups', function() + has_err(function() match('[!]', '') end) -- not a valid pattern + eq(false, match('[!a-z]', '')) + eq(false, match('[!a-z]', 'a')) + eq(false, match('[!a-z]', 'z')) + eq(false, match('[!a-z]', 'j')) + eq(true, match('[!a-f]', 'j')) + eq(false, match('[!a-f]', 'jj')) + eq(true, match('[!a-z]', '`')) -- 'a' - 1 + eq(true, match('[!a-z]', '{')) -- 'z' + 1 + eq(false, match('[!a-zA-Z0-9]', 'a')) + eq(false, match('[!a-zA-Z0-9]', 'A')) + eq(false, match('[!a-zA-Z0-9]', '0')) + eq(true, match('[!a-zA-Z0-9]', '!')) + end) + + it('should match complex patterns', function() + eq(false, match('**/*.{c,h}', '')) + eq(false, match('**/*.{c,h}', 'c')) + eq(true, match('**/*.{c,h}', 'file.c')) + eq(true, match('**/*.{c,h}', 'file.h')) + eq(true, match('**/*.{c,h}', '/file.c')) + eq(true, match('**/*.{c,h}', 'dir/subdir/file.c')) + eq(true, match('**/*.{c,h}', 'dir/subdir/file.h')) + + eq(true, match('{[0-9],[a-z]}', '0')) + eq(true, match('{[0-9],[a-z]}', 'a')) + eq(false, match('{[0-9],[a-z]}', 'A')) + end) + end) +end) diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index f1aad08140..5eb367b0b8 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -1,5 +1,6 @@ local helpers = require('test.functional.helpers')(after_each) local lsp_helpers = require('test.functional.plugin.lsp.helpers') +local lfs = require('lfs') local assert_log = helpers.assert_log local buf_lines = helpers.buf_lines @@ -3589,4 +3590,424 @@ describe('LSP', function() eq(expected, result) end) end) + + describe('vim.lsp._watchfiles', function() + it('sends notifications when files change', function() + local root_dir = helpers.tmpname() + os.remove(root_dir) + lfs.mkdir(root_dir) + + exec_lua(create_server_definition) + local result = exec_lua([[ + local root_dir = ... + + local server = _create_server() + local client_id = vim.lsp.start({ + name = 'watchfiles-test', + cmd = server.cmd, + root_dir = root_dir, + }) + + local expected_messages = 2 -- initialize, initialized + + local msg_wait_timeout = require('vim.lsp._watchfiles')._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() + + vim.lsp.handlers['client/registerCapability'](nil, { + registrations = { + { + id = 'watchfiles-test-0', + method = 'workspace/didChangeWatchedFiles', + registerOptions = { + watchers = { + { + globPattern = '**/watch', + kind = 7, + }, + }, + }, + }, + }, + }, { client_id = client_id }) + + 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) + + local function watched_uri(fname) + return exec_lua([[ + 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() + local root_dir = 'some_dir' + exec_lua(create_server_definition) + local result = exec_lua([[ + local root_dir = ... + + local server = _create_server() + local client_id = vim.lsp.start({ + name = 'watchfiles-test', + cmd = server.cmd, + root_dir = root_dir, + }) + + local expected_messages = 2 -- initialize, initialized + local function wait_for_messages() + assert(vim.wait(200, 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 send_event + require('vim.lsp._watchfiles')._watchfunc = function(_, _, callback) + local stoppped = false + send_event = function(...) + if not stoppped then + callback(...) + end + end + return function() + stoppped = true + end + end + + vim.lsp.handlers['client/registerCapability'](nil, { + registrations = { + { + id = 'watchfiles-test-0', + method = 'workspace/didChangeWatchedFiles', + registerOptions = { + watchers = { + { + globPattern = '**/*.watch0', + }, + }, + }, + }, + }, + }, { client_id = client_id }) + + send_event(root_dir .. '/file.watch0', vim._watch.FileChangeType.Created) + send_event(root_dir .. '/file.watch1', vim._watch.FileChangeType.Created) + + expected_messages = expected_messages + 1 + wait_for_messages() + + vim.lsp.handlers['client/registerCapability'](nil, { + registrations = { + { + id = 'watchfiles-test-1', + method = 'workspace/didChangeWatchedFiles', + registerOptions = { + watchers = { + { + globPattern = '**/*.watch1', + }, + }, + }, + }, + }, + }, { client_id = client_id }) + + vim.lsp.handlers['client/unregisterCapability'](nil, { + unregisterations = { + { + id = 'watchfiles-test-0', + method = 'workspace/didChangeWatchedFiles', + }, + }, + }, { client_id = client_id }) + + send_event(root_dir .. '/file.watch0', vim._watch.FileChangeType.Created) + send_event(root_dir .. '/file.watch1', vim._watch.FileChangeType.Created) + + expected_messages = expected_messages + 1 + wait_for_messages() + + return server.messages + ]], root_dir) + + local function watched_uri(fname) + return exec_lua([[ + 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('file.watch0'), + }, + }, + }, result[3].params) + eq('workspace/didChangeWatchedFiles', result[4].method) + eq({ + changes = { + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('file.watch1'), + }, + }, + }, result[4].params) + end) + + it('correctly handles the registered watch kind', function() + local root_dir = 'some_dir' + exec_lua(create_server_definition) + local result = exec_lua([[ + local root_dir = ... + + local server = _create_server() + local client_id = vim.lsp.start({ + name = 'watchfiles-test', + cmd = server.cmd, + root_dir = root_dir, + }) + + local expected_messages = 2 -- initialize, initialized + local function wait_for_messages() + assert(vim.wait(200, 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 watch_callbacks = {} + local function send_event(...) + for _, cb in ipairs(watch_callbacks) do + cb(...) + end + end + require('vim.lsp._watchfiles')._watchfunc = function(_, _, callback) + table.insert(watch_callbacks, callback) + return function() + -- noop because this test never stops the watch + end + end + + local protocol = require('vim.lsp.protocol') + + local watchers = {} + local max_kind = protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete + for i = 0, max_kind do + local j = i + table.insert(watchers, { + globPattern = { + baseUri = vim.uri_from_fname('/dir'..tostring(i)), + pattern = 'watch'..tostring(i), + }, + kind = i, + }) + end + vim.lsp.handlers['client/registerCapability'](nil, { + registrations = { + { + id = 'watchfiles-test-kind', + method = 'workspace/didChangeWatchedFiles', + registerOptions = { + watchers = watchers, + }, + }, + }, + }, { client_id = client_id }) + + for i = 0, max_kind do + local filename = 'watch'..tostring(i) + send_event(filename, vim._watch.FileChangeType.Created) + send_event(filename, vim._watch.FileChangeType.Changed) + send_event(filename, vim._watch.FileChangeType.Deleted) + end + + expected_messages = expected_messages + 1 + wait_for_messages() + + return server.messages + ]], root_dir) + + local function watched_uri(fname) + return exec_lua([[ + return vim.uri_from_fname(...) + ]], fname) + end + + eq(3, #result) + eq('workspace/didChangeWatchedFiles', result[3].method) + eq({ + changes = { + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('watch1'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Changed]]), + uri = watched_uri('watch2'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('watch3'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Changed]]), + uri = watched_uri('watch3'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]), + uri = watched_uri('watch4'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('watch5'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]), + uri = watched_uri('watch5'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Changed]]), + uri = watched_uri('watch6'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]), + uri = watched_uri('watch6'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('watch7'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Changed]]), + uri = watched_uri('watch7'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Deleted]]), + uri = watched_uri('watch7'), + }, + }, + }, result[3].params) + end) + + it('prunes duplicate events', function() + local root_dir = 'some_dir' + exec_lua(create_server_definition) + local result = exec_lua([[ + local root_dir = ... + + local server = _create_server() + local client_id = vim.lsp.start({ + name = 'watchfiles-test', + cmd = server.cmd, + root_dir = root_dir, + }) + + local expected_messages = 2 -- initialize, initialized + local function wait_for_messages() + assert(vim.wait(200, 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 send_event + require('vim.lsp._watchfiles')._watchfunc = function(_, _, callback) + send_event = callback + return function() + -- noop because this test never stops the watch + end + end + + vim.lsp.handlers['client/registerCapability'](nil, { + registrations = { + { + id = 'watchfiles-test-kind', + method = 'workspace/didChangeWatchedFiles', + registerOptions = { + watchers = { + { + globPattern = '**/*', + }, + }, + }, + }, + }, + }, { client_id = client_id }) + + send_event('file1', vim._watch.FileChangeType.Created) + send_event('file1', vim._watch.FileChangeType.Created) -- pruned + send_event('file1', vim._watch.FileChangeType.Changed) + send_event('file2', vim._watch.FileChangeType.Created) + send_event('file1', vim._watch.FileChangeType.Changed) -- pruned + + expected_messages = expected_messages + 1 + wait_for_messages() + + return server.messages + ]], root_dir) + + local function watched_uri(fname) + return exec_lua([[ + return vim.uri_from_fname(...) + ]], fname) + end + + eq(3, #result) + eq('workspace/didChangeWatchedFiles', result[3].method) + eq({ + changes = { + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('file1'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Changed]]), + uri = watched_uri('file1'), + }, + { + type = exec_lua([[return vim.lsp.protocol.FileChangeType.Created]]), + uri = watched_uri('file2'), + }, + }, + }, result[3].params) + end) + end) end) |