Skip to content

Commit 79a5b89

Browse files
nojnhuhlewis6991
andauthored
perf(lsp): reduce polling handles for workspace/didChangeWatchedFiles (neovim#23500)
Co-authored-by: Lewis Russell <[email protected]>
1 parent 0ce065a commit 79a5b89

File tree

3 files changed

+104
-35
lines changed

3 files changed

+104
-35
lines changed

runtime/lua/vim/_watch.lua

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ M.FileChangeType = vim.tbl_add_reverse_lookup({
1111
--- Joins filepath elements by static '/' separator
1212
---
1313
---@param ... (string) The path elements.
14+
---@return string
1415
local function filepath_join(...)
1516
return table.concat({ ... }, '/')
1617
end
@@ -36,7 +37,7 @@ end
3637
--- - uvflags (table|nil)
3738
--- Same flags as accepted by |uv.fs_event_start()|
3839
---@param callback (function) The function called when new events
39-
---@returns (function) A function to stop the watch
40+
---@return (function) A function to stop the watch
4041
function M.watch(path, opts, callback)
4142
vim.validate({
4243
path = { path, 'string', false },
@@ -75,10 +76,25 @@ end
7576

7677
local default_poll_interval_ms = 2000
7778

79+
--- @class watch.Watches
80+
--- @field is_dir boolean
81+
--- @field children? table<string,watch.Watches>
82+
--- @field cancel? fun()
83+
--- @field started? boolean
84+
--- @field handle? uv_fs_poll_t
85+
86+
--- @class watch.PollOpts
87+
--- @field interval? integer
88+
--- @field include_pattern? userdata
89+
--- @field exclude_pattern? userdata
90+
7891
---@private
7992
--- Implementation for poll, hiding internally-used parameters.
8093
---
81-
---@param watches (table|nil) A tree structure to maintain state for recursive watches.
94+
---@param path string
95+
---@param opts watch.PollOpts
96+
---@param callback fun(patch: string, filechangetype: integer)
97+
---@param watches (watch.Watches|nil) A tree structure to maintain state for recursive watches.
8298
--- - handle (uv_fs_poll_t)
8399
--- The libuv handle
84100
--- - cancel (function)
@@ -88,15 +104,36 @@ local default_poll_interval_ms = 2000
88104
--- be invoked recursively)
89105
--- - children (table|nil)
90106
--- A mapping of directory entry name to its recursive watches
91-
-- - started (boolean|nil)
92-
-- Whether or not the watcher has first been initialized. Used
93-
-- to prevent a flood of Created events on startup.
107+
--- - started (boolean|nil)
108+
--- Whether or not the watcher has first been initialized. Used
109+
--- to prevent a flood of Created events on startup.
110+
---@return fun() Cancel function
94111
local function poll_internal(path, opts, callback, watches)
95112
path = vim.fs.normalize(path)
96113
local interval = opts and opts.interval or default_poll_interval_ms
97114
watches = watches or {
98115
is_dir = true,
99116
}
117+
watches.cancel = function()
118+
if watches.children then
119+
for _, w in pairs(watches.children) do
120+
w.cancel()
121+
end
122+
end
123+
if watches.handle then
124+
stop(watches.handle)
125+
end
126+
end
127+
128+
local function incl_match()
129+
return not opts.include_pattern or opts.include_pattern:match(path) ~= nil
130+
end
131+
local function excl_match()
132+
return opts.exclude_pattern and opts.exclude_pattern:match(path) ~= nil
133+
end
134+
if not watches.is_dir and not incl_match() or excl_match() then
135+
return watches.cancel
136+
end
100137

101138
if not watches.handle then
102139
local poll, new_err = vim.uv.new_fs_poll()
@@ -120,18 +157,9 @@ local function poll_internal(path, opts, callback, watches)
120157
end
121158
end
122159

123-
watches.cancel = function()
124-
if watches.children then
125-
for _, w in pairs(watches.children) do
126-
w.cancel()
127-
end
128-
end
129-
stop(watches.handle)
130-
end
131-
132160
if watches.is_dir then
133161
watches.children = watches.children or {}
134-
local exists = {}
162+
local exists = {} --- @type table<string,true>
135163
for name, ftype in vim.fs.dir(path) do
136164
exists[name] = true
137165
if not watches.children[name] then
@@ -143,14 +171,16 @@ local function poll_internal(path, opts, callback, watches)
143171
end
144172
end
145173

146-
local newchildren = {}
174+
local newchildren = {} ---@type table<string,watch.Watches>
147175
for name, watch in pairs(watches.children) do
148176
if exists[name] then
149177
newchildren[name] = watch
150178
else
151179
watch.cancel()
152180
watches.children[name] = nil
153-
callback(path .. '/' .. name, M.FileChangeType.Deleted)
181+
if watch.handle then
182+
callback(path .. '/' .. name, M.FileChangeType.Deleted)
183+
end
154184
end
155185
end
156186
watches.children = newchildren
@@ -168,6 +198,15 @@ end
168198
---@param opts (table|nil) Additional options
169199
--- - interval (number|nil)
170200
--- Polling interval in ms as passed to |uv.fs_poll_start()|. Defaults to 2000.
201+
--- - include_pattern (LPeg pattern|nil)
202+
--- An |lpeg| pattern. Only changes to files whose full paths match the pattern
203+
--- will be reported. Only matches against non-directoriess, all directories will
204+
--- be watched for new potentially-matching files. exclude_pattern can be used to
205+
--- filter out directories. When nil, matches any file name.
206+
--- - exclude_pattern (LPeg pattern|nil)
207+
--- An |lpeg| pattern. Only changes to files and directories whose full path does
208+
--- not match the pattern will be reported. Matches against both files and
209+
--- directories. When nil, matches nothing.
171210
---@param callback (function) The function called when new events
172211
---@returns (function) A function to stop the watch.
173212
function M.poll(path, opts, callback)

runtime/lua/vim/lsp/_watchfiles.lua

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
local bit = require('bit')
2-
local lpeg = require('lpeg')
32
local watch = require('vim._watch')
43
local protocol = require('vim.lsp.protocol')
4+
local lpeg = vim.lpeg
55

66
local M = {}
77

@@ -107,6 +107,13 @@ local to_lsp_change_type = {
107107
[watch.FileChangeType.Deleted] = protocol.FileChangeType.Deleted,
108108
}
109109

110+
--- Default excludes the same as VSCode's `files.watcherExclude` setting.
111+
--- https://github.com/microsoft/vscode/blob/eef30e7165e19b33daa1e15e92fa34ff4a5df0d3/src/vs/workbench/contrib/files/browser/files.contribution.ts#L261
112+
---@type Lpeg pattern
113+
M._poll_exclude_pattern = parse('**/.git/{objects,subtree-cache}/**')
114+
+ parse('**/node_modules/*/**')
115+
+ parse('**/.hg/store/**')
116+
110117
--- Registers the workspace/didChangeWatchedFiles capability dynamically.
111118
---
112119
---@param reg table LSP Registration object.
@@ -122,10 +129,10 @@ function M.register(reg, ctx)
122129
then
123130
return
124131
end
125-
local watch_regs = {}
132+
local watch_regs = {} --- @type table<string,{pattern:userdata,kind:integer}>
126133
for _, w in ipairs(reg.registerOptions.watchers) do
127134
local relative_pattern = false
128-
local glob_patterns = {}
135+
local glob_patterns = {} --- @type {baseUri:string, pattern: string}[]
129136
if type(w.globPattern) == 'string' then
130137
for _, folder in ipairs(client.workspace_folders) do
131138
table.insert(glob_patterns, { baseUri = folder.uri, pattern = w.globPattern })
@@ -135,7 +142,7 @@ function M.register(reg, ctx)
135142
table.insert(glob_patterns, w.globPattern)
136143
end
137144
for _, glob_pattern in ipairs(glob_patterns) do
138-
local base_dir = nil
145+
local base_dir = nil ---@type string?
139146
if type(glob_pattern.baseUri) == 'string' then
140147
base_dir = glob_pattern.baseUri
141148
elseif type(glob_pattern.baseUri) == 'table' then
@@ -144,6 +151,7 @@ function M.register(reg, ctx)
144151
assert(base_dir, "couldn't identify root of watch")
145152
base_dir = vim.uri_to_fname(base_dir)
146153

154+
---@type integer
147155
local kind = w.kind
148156
or protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete
149157

@@ -153,8 +161,8 @@ function M.register(reg, ctx)
153161
pattern = lpeg.P(base_dir .. '/') * pattern
154162
end
155163

156-
table.insert(watch_regs, {
157-
base_dir = base_dir,
164+
watch_regs[base_dir] = watch_regs[base_dir] or {}
165+
table.insert(watch_regs[base_dir], {
158166
pattern = pattern,
159167
kind = kind,
160168
})
@@ -163,12 +171,12 @@ function M.register(reg, ctx)
163171

164172
local callback = function(base_dir)
165173
return function(fullpath, change_type)
166-
for _, w in ipairs(watch_regs) do
174+
for _, w in ipairs(watch_regs[base_dir]) do
167175
change_type = to_lsp_change_type[change_type]
168176
-- e.g. match kind with Delete bit (0b0100) to Delete change_type (3)
169177
local kind_mask = bit.lshift(1, change_type - 1)
170178
local change_type_match = bit.band(w.kind, kind_mask) == kind_mask
171-
if base_dir == w.base_dir and M._match(w.pattern, fullpath) and change_type_match then
179+
if M._match(w.pattern, fullpath) and change_type_match then
172180
local change = {
173181
uri = vim.uri_from_fname(fullpath),
174182
type = change_type,
@@ -198,15 +206,25 @@ function M.register(reg, ctx)
198206
end
199207
end
200208

201-
local watching = {}
202-
for _, w in ipairs(watch_regs) do
203-
if not watching[w.base_dir] then
204-
watching[w.base_dir] = true
205-
table.insert(
206-
cancels[client_id][reg.id],
207-
M._watchfunc(w.base_dir, { uvflags = { recursive = true } }, callback(w.base_dir))
208-
)
209-
end
209+
for base_dir, watches in pairs(watch_regs) do
210+
local include_pattern = vim.iter(watches):fold(lpeg.P(false), function(acc, w)
211+
return acc + w.pattern
212+
end)
213+
214+
table.insert(
215+
cancels[client_id][reg.id],
216+
M._watchfunc(base_dir, {
217+
uvflags = {
218+
recursive = true,
219+
},
220+
-- include_pattern will ensure the pattern from *any* watcher definition for the
221+
-- base_dir matches. This first pass prevents polling for changes to files that
222+
-- will never be sent to the LSP server. A second pass in the callback is still necessary to
223+
-- match a *particular* pattern+kind pair.
224+
include_pattern = include_pattern,
225+
exclude_pattern = M._poll_exclude_pattern,
226+
}, callback(base_dir))
227+
)
210228
end
211229
end
212230

test/functional/lua/watch_spec.lua

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe('vim._watch', function()
107107
local result = exec_lua(
108108
[[
109109
local root_dir = ...
110+
local lpeg = vim.lpeg
110111
111112
local events = {}
112113
@@ -118,7 +119,13 @@ describe('vim._watch', function()
118119
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))
119120
end
120121
121-
local stop = vim._watch.poll(root_dir, { interval = poll_interval_ms }, function(path, change_type)
122+
local incl = lpeg.P(root_dir) * lpeg.P("/file")^-1
123+
local excl = lpeg.P(root_dir..'/file.unwatched')
124+
local stop = vim._watch.poll(root_dir, {
125+
interval = poll_interval_ms,
126+
include_pattern = incl,
127+
exclude_pattern = excl,
128+
}, function(path, change_type)
122129
table.insert(events, { path = path, change_type = change_type })
123130
end)
124131
@@ -127,12 +134,17 @@ describe('vim._watch', function()
127134
local watched_path = root_dir .. '/file'
128135
local watched, err = io.open(watched_path, 'w')
129136
assert(not err, err)
137+
local unwatched_path = root_dir .. '/file.unwatched'
138+
local unwatched, err = io.open(unwatched_path, 'w')
139+
assert(not err, err)
130140
131141
expected_events = expected_events + 2
132142
wait_for_events()
133143
134144
watched:close()
135145
os.remove(watched_path)
146+
unwatched:close()
147+
os.remove(unwatched_path)
136148
137149
expected_events = expected_events + 2
138150
wait_for_events()

0 commit comments

Comments
 (0)