feat(lsp): implement workspace/didChangeWatchedFiles (#22405)

This commit is contained in:
Jon Huhn 2023-03-05 00:52:27 -06:00 committed by GitHub
parent 419819b624
commit ac69ba5fa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1295 additions and 9 deletions

View File

@ -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*

View File

@ -37,6 +37,7 @@ for k, v in pairs({
health = true,
fs = true,
secure = true,
_watch = true,
}) do
vim._submodules[k] = v
end

174
runtime/lua/vim/_watch.lua Normal file
View File

@ -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

View File

@ -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

View File

@ -117,7 +117,16 @@ M['window/showMessageRequest'] = function(_, result)
end
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability
M['client/registerCapability'] = function(_, _, ctx)
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. '
@ -126,6 +135,17 @@ M['client/registerCapability'] = function(_, _, ctx)
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

View File

@ -834,6 +834,10 @@ function protocol.make_client_capabilities()
semanticTokens = {
refreshSupport = true,
},
didChangeWatchedFiles = {
dynamicRegistration = false,
relativePatternSupport = true,
},
},
experimental = nil,
window = {

View File

@ -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)

View File

@ -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)

View File

@ -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)