Skip to content

Commit ac69ba5

Browse files
authored
feat(lsp): implement workspace/didChangeWatchedFiles (neovim#22405)
1 parent 419819b commit ac69ba5

File tree

9 files changed

+1295
-9
lines changed

9 files changed

+1295
-9
lines changed

runtime/doc/news.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ The following new APIs or features were added.
184184
• Vim's `has('gui_running')` is now supported as a way for plugins to check if
185185
a GUI (not the |TUI|) is attached to Nvim. |has()|
186186

187+
• Added preliminary support for the `workspace/didChangeWatchedFiles` capability
188+
to the LSP client to notify servers of file changes on disk. The feature is
189+
disabled by default and can be enabled by setting the
190+
`workspace.didChangeWatchedFiles.dynamicRegistration=true` capability.
191+
187192
==============================================================================
188193
CHANGED FEATURES *news-changes*
189194

runtime/lua/vim/_editor.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ for k, v in pairs({
3737
health = true,
3838
fs = true,
3939
secure = true,
40+
_watch = true,
4041
}) do
4142
vim._submodules[k] = v
4243
end

runtime/lua/vim/_watch.lua

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
local M = {}
2+
3+
--- Enumeration describing the types of events watchers will emit.
4+
M.FileChangeType = vim.tbl_add_reverse_lookup({
5+
Created = 1,
6+
Changed = 2,
7+
Deleted = 3,
8+
})
9+
10+
---@private
11+
--- Joins filepath elements by static '/' separator
12+
---
13+
---@param ... (string) The path elements.
14+
local function filepath_join(...)
15+
return table.concat({ ... }, '/')
16+
end
17+
18+
---@private
19+
--- Stops and closes a libuv |uv_fs_event_t| or |uv_fs_poll_t| handle
20+
---
21+
---@param handle (uv_fs_event_t|uv_fs_poll_t) The handle to stop
22+
local function stop(handle)
23+
local _, stop_err = handle:stop()
24+
assert(not stop_err, stop_err)
25+
local is_closing, close_err = handle:is_closing()
26+
assert(not close_err, close_err)
27+
if not is_closing then
28+
handle:close()
29+
end
30+
end
31+
32+
--- Initializes and starts a |uv_fs_event_t|
33+
---
34+
---@param path (string) The path to watch
35+
---@param opts (table|nil) Additional options
36+
--- - uvflags (table|nil)
37+
--- Same flags as accepted by |uv.fs_event_start()|
38+
---@param callback (function) The function called when new events
39+
---@returns (function) A function to stop the watch
40+
function M.watch(path, opts, callback)
41+
vim.validate({
42+
path = { path, 'string', false },
43+
opts = { opts, 'table', true },
44+
callback = { callback, 'function', false },
45+
})
46+
47+
path = vim.fs.normalize(path)
48+
local uvflags = opts and opts.uvflags or {}
49+
local handle, new_err = vim.loop.new_fs_event()
50+
assert(not new_err, new_err)
51+
local _, start_err = handle:start(path, uvflags, function(err, filename, events)
52+
assert(not err, err)
53+
local fullpath = path
54+
if filename then
55+
filename = filename:gsub('\\', '/')
56+
fullpath = filepath_join(fullpath, filename)
57+
end
58+
local change_type = events.change and M.FileChangeType.Changed or 0
59+
if events.rename then
60+
local _, staterr, staterrname = vim.loop.fs_stat(fullpath)
61+
if staterrname == 'ENOENT' then
62+
change_type = M.FileChangeType.Deleted
63+
else
64+
assert(not staterr, staterr)
65+
change_type = M.FileChangeType.Created
66+
end
67+
end
68+
callback(fullpath, change_type)
69+
end)
70+
assert(not start_err, start_err)
71+
return function()
72+
stop(handle)
73+
end
74+
end
75+
76+
local default_poll_interval_ms = 2000
77+
78+
---@private
79+
--- Implementation for poll, hiding internally-used parameters.
80+
---
81+
---@param watches (table|nil) A tree structure to maintain state for recursive watches.
82+
--- - handle (uv_fs_poll_t)
83+
--- The libuv handle
84+
--- - cancel (function)
85+
--- A function that cancels the handle and all children's handles
86+
--- - is_dir (boolean)
87+
--- Indicates whether the path is a directory (and the poll should
88+
--- be invoked recursively)
89+
--- - children (table|nil)
90+
--- A mapping of directory entry name to its recursive watches
91+
local function poll_internal(path, opts, callback, watches)
92+
path = vim.fs.normalize(path)
93+
local interval = opts and opts.interval or default_poll_interval_ms
94+
watches = watches or {
95+
is_dir = true,
96+
}
97+
98+
if not watches.handle then
99+
local poll, new_err = vim.loop.new_fs_poll()
100+
assert(not new_err, new_err)
101+
watches.handle = poll
102+
local _, start_err = poll:start(
103+
path,
104+
interval,
105+
vim.schedule_wrap(function(err)
106+
if err == 'ENOENT' then
107+
return
108+
end
109+
assert(not err, err)
110+
poll_internal(path, opts, callback, watches)
111+
callback(path, M.FileChangeType.Changed)
112+
end)
113+
)
114+
assert(not start_err, start_err)
115+
callback(path, M.FileChangeType.Created)
116+
end
117+
118+
watches.cancel = function()
119+
if watches.children then
120+
for _, w in pairs(watches.children) do
121+
w.cancel()
122+
end
123+
end
124+
stop(watches.handle)
125+
end
126+
127+
if watches.is_dir then
128+
watches.children = watches.children or {}
129+
local exists = {}
130+
for name, ftype in vim.fs.dir(path) do
131+
exists[name] = true
132+
if not watches.children[name] then
133+
watches.children[name] = {
134+
is_dir = ftype == 'directory',
135+
}
136+
poll_internal(filepath_join(path, name), opts, callback, watches.children[name])
137+
end
138+
end
139+
140+
local newchildren = {}
141+
for name, watch in pairs(watches.children) do
142+
if exists[name] then
143+
newchildren[name] = watch
144+
else
145+
watch.cancel()
146+
watches.children[name] = nil
147+
callback(path .. '/' .. name, M.FileChangeType.Deleted)
148+
end
149+
end
150+
watches.children = newchildren
151+
end
152+
153+
return watches.cancel
154+
end
155+
156+
--- Initializes and starts a |uv_fs_poll_t| recursively watching every file underneath the
157+
--- directory at path.
158+
---
159+
---@param path (string) The path to watch. Must refer to a directory.
160+
---@param opts (table|nil) Additional options
161+
--- - interval (number|nil)
162+
--- Polling interval in ms as passed to |uv.fs_poll_start()|. Defaults to 2000.
163+
---@param callback (function) The function called when new events
164+
---@returns (function) A function to stop the watch.
165+
function M.poll(path, opts, callback)
166+
vim.validate({
167+
path = { path, 'string', false },
168+
opts = { opts, 'table', true },
169+
callback = { callback, 'function', false },
170+
})
171+
return poll_internal(path, opts, callback, nil)
172+
end
173+
174+
return M

0 commit comments

Comments
 (0)