aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/lsp/_watchfiles.lua
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/lua/vim/lsp/_watchfiles.lua')
-rw-r--r--runtime/lua/vim/lsp/_watchfiles.lua251
1 files changed, 251 insertions, 0 deletions
diff --git a/runtime/lua/vim/lsp/_watchfiles.lua b/runtime/lua/vim/lsp/_watchfiles.lua
new file mode 100644
index 0000000000..1fd112631d
--- /dev/null
+++ b/runtime/lua/vim/lsp/_watchfiles.lua
@@ -0,0 +1,251 @@
+local bit = require('bit')
+local watch = require('vim._watch')
+local protocol = require('vim.lsp.protocol')
+local ms = protocol.Methods
+local lpeg = vim.lpeg
+
+local M = {}
+
+--- Parses the raw pattern into an |lpeg| pattern. LPeg patterns natively support the "this" or "that"
+--- alternative constructions described in the LSP spec that cannot be expressed in a standard Lua pattern.
+---
+---@param pattern string The raw glob pattern
+---@return vim.lpeg.Pattern? pattern An |lpeg| representation of the pattern, or nil if the pattern is invalid.
+local function parse(pattern)
+ local l = lpeg
+
+ local P, S, V = lpeg.P, lpeg.S, lpeg.V
+ local C, Cc, Ct, Cf = lpeg.C, lpeg.Cc, lpeg.Ct, lpeg.Cf
+
+ local pathsep = '/'
+
+ local function class(inv, ranges)
+ for i, r in ipairs(ranges) do
+ ranges[i] = r[1] .. r[2]
+ end
+ local patt = l.R(unpack(ranges))
+ if inv == '!' then
+ patt = P(1) - patt
+ end
+ return patt
+ end
+
+ local function add(acc, a)
+ return acc + a
+ end
+
+ local function mul(acc, m)
+ return acc * m
+ end
+
+ local function star(stars, after)
+ return (-after * (l.P(1) - pathsep)) ^ #stars * after
+ end
+
+ local function dstar(after)
+ return (-after * l.P(1)) ^ 0 * after
+ end
+
+ local p = P({
+ 'Pattern',
+ Pattern = V('Elem') ^ -1 * V('End'),
+ Elem = Cf(
+ (V('DStar') + V('Star') + V('Ques') + V('Class') + V('CondList') + V('Literal'))
+ * (V('Elem') + V('End')),
+ mul
+ ),
+ DStar = P('**') * (P(pathsep) * (V('Elem') + V('End')) + V('End')) / dstar,
+ Star = C(P('*') ^ 1) * (V('Elem') + V('End')) / star,
+ Ques = P('?') * Cc(l.P(1) - pathsep),
+ Class = P('[') * C(P('!') ^ -1) * Ct(Ct(C(1) * '-' * C(P(1) - ']')) ^ 1 * ']') / class,
+ CondList = P('{') * Cf(V('Cond') * (P(',') * V('Cond')) ^ 0, add) * '}',
+ -- TODO: '*' inside a {} condition is interpreted literally but should probably have the same
+ -- wildcard semantics it usually has.
+ -- Fixing this is non-trivial because '*' should match non-greedily up to "the rest of the
+ -- pattern" which in all other cases is the entire succeeding part of the pattern, but at the end of a {}
+ -- condition means "everything after the {}" where several other options separated by ',' may
+ -- exist in between that should not be matched by '*'.
+ Cond = Cf((V('Ques') + V('Class') + V('CondList') + (V('Literal') - S(',}'))) ^ 1, mul)
+ + Cc(l.P(0)),
+ Literal = P(1) / l.P,
+ End = P(-1) * Cc(l.P(-1)),
+ })
+
+ return p:match(pattern) --[[@as vim.lpeg.Pattern?]]
+end
+
+---@private
+--- Implementation of LSP 3.17.0's pattern matching: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pattern
+---
+---@param pattern string|vim.lpeg.Pattern 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
+ local p = assert(parse(pattern))
+ return p:match(s) ~= nil
+ end
+ return pattern:match(s) ~= nil
+end
+
+M._watchfunc = (vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1) and watch.watch or watch.poll
+
+---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function
+local cancels = vim.defaulttable()
+
+local queue_timeout_ms = 100
+---@type table<integer, uv.uv_timer_t> client id -> libuv timer which will send queued changes at its timeout
+local queue_timers = {}
+---@type table<integer, lsp.FileEvent[]> client id -> set of queued changes to send in a single LSP notification
+local change_queues = {}
+---@type table<integer, 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()
+
+---@type table<vim._watch.FileChangeType, lsp.FileChangeType>
+local to_lsp_change_type = {
+ [watch.FileChangeType.Created] = protocol.FileChangeType.Created,
+ [watch.FileChangeType.Changed] = protocol.FileChangeType.Changed,
+ [watch.FileChangeType.Deleted] = protocol.FileChangeType.Deleted,
+}
+
+--- Default excludes the same as VSCode's `files.watcherExclude` setting.
+--- https://github.com/microsoft/vscode/blob/eef30e7165e19b33daa1e15e92fa34ff4a5df0d3/src/vs/workbench/contrib/files/browser/files.contribution.ts#L261
+---@type vim.lpeg.Pattern parsed Lpeg pattern
+M._poll_exclude_pattern = parse('**/.git/{objects,subtree-cache}/**')
+ + parse('**/node_modules/*/**')
+ + parse('**/.hg/store/**')
+
+--- Registers the workspace/didChangeWatchedFiles capability dynamically.
+---
+---@param reg lsp.Registration LSP Registration object.
+---@param ctx lsp.HandlerContext Context from the |lsp-handler|.
+function M.register(reg, ctx)
+ local client_id = ctx.client_id
+ local client = assert(vim.lsp.get_client_by_id(client_id), 'Client must be running')
+ -- Ill-behaved servers may not honor the client capability and try to register
+ -- anyway, so ignore requests when the user has opted out of the feature.
+ local has_capability = vim.tbl_get(
+ client.config.capabilities or {},
+ 'workspace',
+ 'didChangeWatchedFiles',
+ 'dynamicRegistration'
+ )
+ if not has_capability or not client.workspace_folders then
+ return
+ end
+ local register_options = reg.registerOptions --[[@as lsp.DidChangeWatchedFilesRegistrationOptions]]
+ ---@type table<string, {pattern: vim.lpeg.Pattern, kind: lsp.WatchKind}[]> by base_dir
+ local watch_regs = vim.defaulttable()
+ for _, w in ipairs(register_options.watchers) do
+ local kind = w.kind
+ or (protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete)
+ local glob_pattern = w.globPattern
+
+ if type(glob_pattern) == 'string' then
+ local pattern = parse(glob_pattern)
+ if not pattern then
+ error('Cannot parse pattern: ' .. glob_pattern)
+ end
+ for _, folder in ipairs(client.workspace_folders) do
+ local base_dir = vim.uri_to_fname(folder.uri)
+ table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
+ end
+ else
+ local base_uri = glob_pattern.baseUri
+ local uri = type(base_uri) == 'string' and base_uri or base_uri.uri
+ local base_dir = vim.uri_to_fname(uri)
+ local pattern = parse(glob_pattern.pattern)
+ if not pattern then
+ error('Cannot parse pattern: ' .. glob_pattern.pattern)
+ end
+ pattern = lpeg.P(base_dir .. '/') * pattern
+ table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
+ end
+ end
+
+ ---@param base_dir string
+ local callback = function(base_dir)
+ return function(fullpath, change_type)
+ local registrations = watch_regs[base_dir]
+ for _, w in ipairs(registrations) do
+ local lsp_change_type = assert(
+ to_lsp_change_type[change_type],
+ 'Must receive change type Created, Changed or Deleted'
+ )
+ -- e.g. match kind with Delete bit (0b0100) to Delete change_type (3)
+ local kind_mask = bit.lshift(1, lsp_change_type - 1)
+ local change_type_match = bit.band(w.kind, kind_mask) == kind_mask
+ if w.pattern:match(fullpath) ~= nil and change_type_match then
+ ---@type lsp.FileEvent
+ local change = {
+ uri = vim.uri_from_fname(fullpath),
+ type = lsp_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()
+ ---@type lsp.DidChangeWatchedFilesParams
+ local params = {
+ changes = change_queues[client_id],
+ }
+ client.notify(ms.workspace_didChangeWatchedFiles, params)
+ 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
+
+ for base_dir, watches in pairs(watch_regs) do
+ local include_pattern = vim.iter(watches):fold(lpeg.P(false), function(acc, w)
+ return acc + w.pattern
+ end)
+
+ table.insert(
+ cancels[client_id][reg.id],
+ M._watchfunc(base_dir, {
+ uvflags = {
+ recursive = true,
+ },
+ -- include_pattern will ensure the pattern from *any* watcher definition for the
+ -- base_dir matches. This first pass prevents polling for changes to files that
+ -- will never be sent to the LSP server. A second pass in the callback is still necessary to
+ -- match a *particular* pattern+kind pair.
+ include_pattern = include_pattern,
+ exclude_pattern = M._poll_exclude_pattern,
+ }, callback(base_dir))
+ )
+ end
+end
+
+--- Unregisters the workspace/didChangeWatchedFiles capability dynamically.
+---
+---@param unreg lsp.Unregistration LSP Unregistration object.
+---@param ctx lsp.HandlerContext 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