aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/_watch.lua
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/lua/vim/_watch.lua')
-rw-r--r--runtime/lua/vim/_watch.lua304
1 files changed, 197 insertions, 107 deletions
diff --git a/runtime/lua/vim/_watch.lua b/runtime/lua/vim/_watch.lua
index 43fce3bf7f..97c5481ad1 100644
--- a/runtime/lua/vim/_watch.lua
+++ b/runtime/lua/vim/_watch.lua
@@ -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
+---
+--- 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
---
----@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 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,110 +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)
+ 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 = {}
-
- 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
+ --- 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 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
@@ -158,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?
@@ -174,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
@@ -190,34 +218,96 @@ 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,
+ }, {
+ stderr = function(err, data)
+ if err then
+ error(err)
+ end
+
+ if data and #vim.trim(data) > 0 then
+ vim.schedule(function()
+ vim.notify('fswatch: ' .. data, vim.log.levels.ERROR)
+ end)
+ end
+ end,
+ 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