diff options
author | Jon Huhn <nojnhuh@users.noreply.github.com> | 2023-02-25 03:07:18 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-25 10:07:18 +0100 |
commit | 5732aa706c639b3d775573d91d1139f24624629c (patch) | |
tree | ff6e67f1712d1a502b6437aae17b25ef822b63f2 /runtime/lua/vim | |
parent | a601d031127689a7d60cc92f4ec89907794ed020 (diff) | |
download | rneovim-5732aa706c639b3d775573d91d1139f24624629c.tar.gz rneovim-5732aa706c639b3d775573d91d1139f24624629c.tar.bz2 rneovim-5732aa706c639b3d775573d91d1139f24624629c.zip |
feat(lsp): implement workspace/didChangeWatchedFiles (#21293)
Diffstat (limited to 'runtime/lua/vim')
-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 | 274 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 38 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 9 |
5 files changed, 487 insertions, 9 deletions
diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua index 3f27e61810..92444ff550 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..b9268b963c --- /dev/null +++ b/runtime/lua/vim/lsp/_watchfiles.lua @@ -0,0 +1,274 @@ +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) + 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( + cancels[client_id][reg.id], + M._watchfunc(base_dir, { uvflags = { recursive = true } }, function(fullpath, change_type) + 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(kind, kind_mask) == kind_mask + if not M._match(pattern, fullpath) or not change_type_match then + return + end + + 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 + end) + ) + 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 12345b6c8c..df1ab26667 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -28,6 +28,10 @@ end ---@class lsp.MessageActionItem ---@field title string +---@class lsp.FileEvent +---@field uri string +---@field type lsp.FileChangeType + local constants = { DiagnosticSeverity = { -- Reports an error. @@ -60,6 +64,7 @@ local constants = { }, -- The file event type. + ---@enum lsp.FileChangeType FileChangeType = { -- The file got created. Created = 1, @@ -841,6 +846,10 @@ function protocol.make_client_capabilities() semanticTokens = { refreshSupport = true, }, + didChangeWatchedFiles = { + dynamicRegistration = true, + relativePatternSupport = true, + }, }, experimental = nil, window = { |