diff options
Diffstat (limited to 'runtime/lua/vim/_watch.lua')
-rw-r--r-- | runtime/lua/vim/_watch.lua | 304 |
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 |