mirror of
https://github.com/neovim/neovim.git
synced 2025-09-07 11:58:17 +00:00
Merge pull request #27347 from lewis6991/fswatch
feat(lsp): add fswatch watchfunc backend
This commit is contained in:
@@ -1,45 +1,61 @@
|
||||
local M = {}
|
||||
local uv = vim.uv
|
||||
|
||||
---@enum vim._watch.FileChangeType
|
||||
local FileChangeType = {
|
||||
local M = {}
|
||||
|
||||
--- @enum vim._watch.FileChangeType
|
||||
--- Types of events watchers will emit.
|
||||
M.FileChangeType = {
|
||||
Created = 1,
|
||||
Changed = 2,
|
||||
Deleted = 3,
|
||||
}
|
||||
|
||||
--- Enumeration describing the types of events watchers will emit.
|
||||
M.FileChangeType = vim.tbl_add_reverse_lookup(FileChangeType)
|
||||
|
||||
--- Joins filepath elements by static '/' separator
|
||||
--- @class vim._watch.Opts
|
||||
---
|
||||
---@param ... (string) The path elements.
|
||||
---@return string
|
||||
local function filepath_join(...)
|
||||
return table.concat({ ... }, '/')
|
||||
end
|
||||
|
||||
--- Stops and closes a libuv |uv_fs_event_t| or |uv_fs_poll_t| handle
|
||||
--- @field debounce? integer ms
|
||||
---
|
||||
---@param handle (uv.uv_fs_event_t|uv.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()
|
||||
--- An |lpeg| pattern. Only changes to files whose full paths match the pattern
|
||||
--- will be reported. Only matches against non-directoriess, all directories will
|
||||
--- be watched for new potentially-matching files. exclude_pattern can be used to
|
||||
--- filter out directories. When nil, matches any file name.
|
||||
--- @field include_pattern? vim.lpeg.Pattern
|
||||
---
|
||||
--- An |lpeg| pattern. Only changes to files and directories whose full path does
|
||||
--- not match the pattern will be reported. Matches against both files and
|
||||
--- directories. When nil, matches nothing.
|
||||
--- @field exclude_pattern? vim.lpeg.Pattern
|
||||
|
||||
--- @alias vim._watch.Callback fun(path: string, change_type: vim._watch.FileChangeType)
|
||||
|
||||
--- @class vim._watch.watch.Opts : vim._watch.Opts
|
||||
--- @field uvflags? uv.fs_event_start.flags
|
||||
|
||||
--- @param path string
|
||||
--- @param opts? vim._watch.Opts
|
||||
local function skip(path, opts)
|
||||
if not opts then
|
||||
return false
|
||||
end
|
||||
|
||||
if opts.include_pattern and opts.include_pattern:match(path) == nil then
|
||||
return true
|
||||
end
|
||||
|
||||
if opts.exclude_pattern and opts.exclude_pattern:match(path) ~= nil then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
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
|
||||
---@return (function) Stops the watcher
|
||||
--- @param path string The path to watch
|
||||
--- @param opts vim._watch.watch.Opts? Additional options:
|
||||
--- - uvflags (table|nil)
|
||||
--- Same flags as accepted by |uv.fs_event_start()|
|
||||
--- @param callback vim._watch.Callback Callback for new events
|
||||
--- @return fun() cancel Stops the watcher
|
||||
function M.watch(path, opts, callback)
|
||||
vim.validate({
|
||||
path = { path, 'string', false },
|
||||
@@ -47,111 +63,120 @@ function M.watch(path, opts, callback)
|
||||
callback = { callback, 'function', false },
|
||||
})
|
||||
|
||||
opts = opts or {}
|
||||
|
||||
path = vim.fs.normalize(path)
|
||||
local uvflags = opts and opts.uvflags or {}
|
||||
local handle, new_err = vim.uv.new_fs_event()
|
||||
assert(not new_err, new_err)
|
||||
handle = assert(handle)
|
||||
local handle = assert(uv.new_fs_event())
|
||||
|
||||
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)
|
||||
fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename))
|
||||
end
|
||||
local change_type = events.change and M.FileChangeType.Changed or 0
|
||||
|
||||
if skip(fullpath, opts) then
|
||||
return
|
||||
end
|
||||
|
||||
--- @type vim._watch.FileChangeType
|
||||
local change_type
|
||||
if events.rename then
|
||||
local _, staterr, staterrname = vim.uv.fs_stat(fullpath)
|
||||
local _, staterr, staterrname = uv.fs_stat(fullpath)
|
||||
if staterrname == 'ENOENT' then
|
||||
change_type = M.FileChangeType.Deleted
|
||||
else
|
||||
assert(not staterr, staterr)
|
||||
change_type = M.FileChangeType.Created
|
||||
end
|
||||
elseif events.change then
|
||||
change_type = M.FileChangeType.Changed
|
||||
end
|
||||
callback(fullpath, change_type)
|
||||
end)
|
||||
|
||||
assert(not start_err, start_err)
|
||||
|
||||
return 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
|
||||
end
|
||||
|
||||
--- @class watch.PollOpts
|
||||
--- @field debounce? integer
|
||||
--- @field include_pattern? vim.lpeg.Pattern
|
||||
--- @field exclude_pattern? vim.lpeg.Pattern
|
||||
--- Initializes and starts a |uv_fs_event_t| recursively watching every directory underneath the
|
||||
--- directory at path.
|
||||
---
|
||||
--- @param path string The path to watch. Must refer to a directory.
|
||||
--- @param opts vim._watch.Opts? Additional options
|
||||
--- @param callback vim._watch.Callback Callback for new events
|
||||
--- @return fun() cancel Stops the watcher
|
||||
function M.watchdirs(path, opts, callback)
|
||||
vim.validate({
|
||||
path = { path, 'string', false },
|
||||
opts = { opts, 'table', true },
|
||||
callback = { callback, 'function', false },
|
||||
})
|
||||
|
||||
---@param path string
|
||||
---@param opts watch.PollOpts
|
||||
---@param callback function Called on new events
|
||||
---@return function cancel stops the watcher
|
||||
local function recurse_watch(path, opts, callback)
|
||||
opts = opts or {}
|
||||
local debounce = opts.debounce or 500
|
||||
local uvflags = {}
|
||||
|
||||
---@type table<string, uv.uv_fs_event_t> handle by fullpath
|
||||
local handles = {}
|
||||
|
||||
local timer = assert(uv.new_timer())
|
||||
|
||||
---@type table[]
|
||||
local changesets = {}
|
||||
--- Map of file path to boolean indicating if the file has been changed
|
||||
--- at some point within the debounce cycle.
|
||||
--- @type table<string, boolean>
|
||||
local filechanges = {}
|
||||
|
||||
local function is_included(filepath)
|
||||
return opts.include_pattern and opts.include_pattern:match(filepath)
|
||||
end
|
||||
local function is_excluded(filepath)
|
||||
return opts.exclude_pattern and opts.exclude_pattern:match(filepath)
|
||||
end
|
||||
|
||||
local process_changes = function()
|
||||
assert(false, "Replaced later. I'm only here as forward reference")
|
||||
end
|
||||
local process_changes --- @type fun()
|
||||
|
||||
--- @param filepath string
|
||||
--- @return uv.fs_event_start.callback
|
||||
local function create_on_change(filepath)
|
||||
return function(err, filename, events)
|
||||
assert(not err, err)
|
||||
local fullpath = vim.fs.joinpath(filepath, filename)
|
||||
if is_included(fullpath) and not is_excluded(filepath) then
|
||||
table.insert(changesets, {
|
||||
fullpath = fullpath,
|
||||
events = events,
|
||||
})
|
||||
timer:start(debounce, 0, process_changes)
|
||||
if skip(fullpath, opts) then
|
||||
return
|
||||
end
|
||||
|
||||
if not filechanges[fullpath] then
|
||||
filechanges[fullpath] = events.change or false
|
||||
end
|
||||
timer:start(debounce, 0, process_changes)
|
||||
end
|
||||
end
|
||||
|
||||
process_changes = function()
|
||||
---@type table<string, table[]>
|
||||
local filechanges = vim.defaulttable()
|
||||
for i, change in ipairs(changesets) do
|
||||
changesets[i] = nil
|
||||
if is_included(change.fullpath) and not is_excluded(change.fullpath) then
|
||||
table.insert(filechanges[change.fullpath], change.events)
|
||||
end
|
||||
end
|
||||
for fullpath, events_list in pairs(filechanges) do
|
||||
-- Since the callback is debounced it may have also been deleted later on
|
||||
-- so we always need to check the existence of the file:
|
||||
-- stat succeeds, changed=true -> Changed
|
||||
-- stat succeeds, changed=false -> Created
|
||||
-- stat fails -> Removed
|
||||
for fullpath, changed in pairs(filechanges) do
|
||||
uv.fs_stat(fullpath, function(_, stat)
|
||||
---@type vim._watch.FileChangeType
|
||||
local change_type
|
||||
if stat then
|
||||
change_type = FileChangeType.Created
|
||||
for _, event in ipairs(events_list) do
|
||||
if event.change then
|
||||
change_type = FileChangeType.Changed
|
||||
end
|
||||
end
|
||||
change_type = changed and M.FileChangeType.Changed or M.FileChangeType.Created
|
||||
if stat.type == 'directory' then
|
||||
local handle = handles[fullpath]
|
||||
if not handle then
|
||||
handle = assert(uv.new_fs_event())
|
||||
handles[fullpath] = handle
|
||||
handle:start(fullpath, uvflags, create_on_change(fullpath))
|
||||
handle:start(fullpath, {}, create_on_change(fullpath))
|
||||
end
|
||||
end
|
||||
else
|
||||
change_type = M.FileChangeType.Deleted
|
||||
local handle = handles[fullpath]
|
||||
if handle then
|
||||
if not handle:is_closing() then
|
||||
@@ -159,15 +184,16 @@ local function recurse_watch(path, opts, callback)
|
||||
end
|
||||
handles[fullpath] = nil
|
||||
end
|
||||
change_type = FileChangeType.Deleted
|
||||
end
|
||||
callback(fullpath, change_type)
|
||||
end)
|
||||
end
|
||||
filechanges = {}
|
||||
end
|
||||
|
||||
local root_handle = assert(uv.new_fs_event())
|
||||
handles[path] = root_handle
|
||||
root_handle:start(path, uvflags, create_on_change(path))
|
||||
root_handle:start(path, {}, create_on_change(path))
|
||||
|
||||
--- "640K ought to be enough for anyone"
|
||||
--- Who has folders this deep?
|
||||
@@ -175,12 +201,13 @@ local function recurse_watch(path, opts, callback)
|
||||
|
||||
for name, type in vim.fs.dir(path, { depth = max_depth }) do
|
||||
local filepath = vim.fs.joinpath(path, name)
|
||||
if type == 'directory' and not is_excluded(filepath) then
|
||||
if type == 'directory' and not skip(filepath, opts) then
|
||||
local handle = assert(uv.new_fs_event())
|
||||
handles[filepath] = handle
|
||||
handle:start(filepath, uvflags, create_on_change(filepath))
|
||||
handle:start(filepath, {}, create_on_change(filepath))
|
||||
end
|
||||
end
|
||||
|
||||
local function cancel()
|
||||
for fullpath, handle in pairs(handles) do
|
||||
if not handle:is_closing() then
|
||||
@@ -191,34 +218,85 @@ local function recurse_watch(path, opts, callback)
|
||||
timer:stop()
|
||||
timer:close()
|
||||
end
|
||||
|
||||
return 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
|
||||
--- - debounce (number|nil)
|
||||
--- Time events are debounced in ms. Defaults to 500
|
||||
--- - include_pattern (LPeg pattern|nil)
|
||||
--- An |lpeg| pattern. Only changes to files whose full paths match the pattern
|
||||
--- will be reported. Only matches against non-directoriess, all directories will
|
||||
--- be watched for new potentially-matching files. exclude_pattern can be used to
|
||||
--- filter out directories. When nil, matches any file name.
|
||||
--- - exclude_pattern (LPeg pattern|nil)
|
||||
--- An |lpeg| pattern. Only changes to files and directories whose full path does
|
||||
--- not match the pattern will be reported. Matches against both files and
|
||||
--- directories. When nil, matches nothing.
|
||||
---@param callback (function) The function called when new events
|
||||
---@return function Stops the watcher
|
||||
function M.poll(path, opts, callback)
|
||||
vim.validate({
|
||||
path = { path, 'string', false },
|
||||
opts = { opts, 'table', true },
|
||||
callback = { callback, 'function', false },
|
||||
--- @param data string
|
||||
--- @param opts vim._watch.Opts?
|
||||
--- @param callback vim._watch.Callback
|
||||
local function fswatch_output_handler(data, opts, callback)
|
||||
local d = vim.split(data, '%s+')
|
||||
|
||||
-- only consider the last reported event
|
||||
local fullpath, event = d[1], d[#d]
|
||||
|
||||
if skip(fullpath, opts) then
|
||||
return
|
||||
end
|
||||
|
||||
--- @type integer
|
||||
local change_type
|
||||
|
||||
if event == 'Created' then
|
||||
change_type = M.FileChangeType.Created
|
||||
elseif event == 'Removed' then
|
||||
change_type = M.FileChangeType.Deleted
|
||||
elseif event == 'Updated' then
|
||||
change_type = M.FileChangeType.Changed
|
||||
elseif event == 'Renamed' then
|
||||
local _, staterr, staterrname = uv.fs_stat(fullpath)
|
||||
if staterrname == 'ENOENT' then
|
||||
change_type = M.FileChangeType.Deleted
|
||||
else
|
||||
assert(not staterr, staterr)
|
||||
change_type = M.FileChangeType.Created
|
||||
end
|
||||
end
|
||||
|
||||
if change_type then
|
||||
callback(fullpath, change_type)
|
||||
end
|
||||
end
|
||||
|
||||
--- @param path string The path to watch. Must refer to a directory.
|
||||
--- @param opts vim._watch.Opts?
|
||||
--- @param callback vim._watch.Callback Callback for new events
|
||||
--- @return fun() cancel Stops the watcher
|
||||
function M.fswatch(path, opts, callback)
|
||||
-- debounce isn't the same as latency but close enough
|
||||
local latency = 0.5 -- seconds
|
||||
if opts and opts.debounce then
|
||||
latency = opts.debounce / 1000
|
||||
end
|
||||
|
||||
local obj = vim.system({
|
||||
'fswatch',
|
||||
'--event=Created',
|
||||
'--event=Removed',
|
||||
'--event=Updated',
|
||||
'--event=Renamed',
|
||||
'--event-flags',
|
||||
'--recursive',
|
||||
'--latency=' .. tostring(latency),
|
||||
'--exclude',
|
||||
'/.git/',
|
||||
path,
|
||||
}, {
|
||||
stdout = function(err, data)
|
||||
if err then
|
||||
error(err)
|
||||
end
|
||||
|
||||
for line in vim.gsplit(data or '', '\n', { plain = true, trimempty = true }) do
|
||||
fswatch_output_handler(line, opts, callback)
|
||||
end
|
||||
end,
|
||||
})
|
||||
return recurse_watch(path, opts, callback)
|
||||
|
||||
return function()
|
||||
obj:kill(2)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
@@ -7,7 +7,13 @@ local lpeg = vim.lpeg
|
||||
|
||||
local M = {}
|
||||
|
||||
M._watchfunc = (vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1) and watch.watch or watch.poll
|
||||
if vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1 then
|
||||
M._watchfunc = watch.watch
|
||||
elseif vim.fn.executable('fswatch') == 1 then
|
||||
M._watchfunc = watch.fswatch
|
||||
else
|
||||
M._watchfunc = watch.watchdirs
|
||||
end
|
||||
|
||||
---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function
|
||||
local cancels = vim.defaulttable()
|
||||
@@ -163,4 +169,13 @@ function M.unregister(unreg, ctx)
|
||||
end
|
||||
end
|
||||
|
||||
--- @param client_id integer
|
||||
function M.cancel(client_id)
|
||||
for _, reg_cancels in pairs(cancels[client_id]) do
|
||||
for _, cancel in pairs(reg_cancels) do
|
||||
cancel()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
@@ -815,6 +815,7 @@ function Client:_stop(force)
|
||||
rpc.terminate()
|
||||
self._graceful_shutdown_failed = true
|
||||
end
|
||||
vim.lsp._watchfiles.cancel(self.id)
|
||||
end)
|
||||
end
|
||||
|
||||
|
@@ -1,10 +1,9 @@
|
||||
local M = {}
|
||||
|
||||
--- Performs a healthcheck for LSP
|
||||
function M.check()
|
||||
local report_info = vim.health.info
|
||||
local report_warn = vim.health.warn
|
||||
local report_info = vim.health.info
|
||||
local report_warn = vim.health.warn
|
||||
|
||||
local function check_log()
|
||||
local log = vim.lsp.log
|
||||
local current_log_level = log.get_level()
|
||||
local log_level_string = log.levels[current_log_level] ---@type string
|
||||
@@ -27,9 +26,11 @@ function M.check()
|
||||
|
||||
local report_fn = (log_size / 1000000 > 100 and report_warn or report_info)
|
||||
report_fn(string.format('Log size: %d KB', log_size / 1000))
|
||||
end
|
||||
|
||||
local clients = vim.lsp.get_clients()
|
||||
local function check_active_clients()
|
||||
vim.health.start('vim.lsp: Active Clients')
|
||||
local clients = vim.lsp.get_clients()
|
||||
if next(clients) then
|
||||
for _, client in pairs(clients) do
|
||||
local attached_to = table.concat(vim.tbl_keys(client.attached_buffers or {}), ',')
|
||||
@@ -48,4 +49,33 @@ function M.check()
|
||||
end
|
||||
end
|
||||
|
||||
local function check_watcher()
|
||||
vim.health.start('vim.lsp: File watcher')
|
||||
local watchfunc = vim.lsp._watchfiles._watchfunc
|
||||
assert(watchfunc)
|
||||
local watchfunc_name --- @type string
|
||||
if watchfunc == vim._watch.watch then
|
||||
watchfunc_name = 'libuv-watch'
|
||||
elseif watchfunc == vim._watch.watchdirs then
|
||||
watchfunc_name = 'libuv-watchdirs'
|
||||
elseif watchfunc == vim._watch.fswatch then
|
||||
watchfunc_name = 'fswatch'
|
||||
else
|
||||
local nm = debug.getinfo(watchfunc, 'S').source
|
||||
watchfunc_name = string.format('Custom (%s)', nm)
|
||||
end
|
||||
|
||||
report_info('File watch backend: ' .. watchfunc_name)
|
||||
if watchfunc_name == 'libuv-watchdirs' then
|
||||
report_warn('libuv-watchdirs has known performance issues. Consider installing fswatch.')
|
||||
end
|
||||
end
|
||||
|
||||
--- Performs a healthcheck for LSP
|
||||
function M.check()
|
||||
check_log()
|
||||
check_active_clients()
|
||||
check_watcher()
|
||||
end
|
||||
|
||||
return M
|
||||
|
Reference in New Issue
Block a user