_watch.lua (10056B)
1 local uv = vim.uv 2 3 local M = {} 4 5 --- @enum vim._watch.FileChangeType 6 --- Types of events watchers will emit. 7 M.FileChangeType = { 8 Created = 1, 9 Changed = 2, 10 Deleted = 3, 11 } 12 13 --- @class vim._watch.Opts 14 --- 15 --- @field debounce? integer ms 16 --- 17 --- An |lpeg| pattern. Only changes to files whose full paths match the pattern 18 --- will be reported. Only matches against non-directoriess, all directories will 19 --- be watched for new potentially-matching files. exclude_pattern can be used to 20 --- filter out directories. When nil, matches any file name. 21 --- @field include_pattern? vim.lpeg.Pattern 22 --- 23 --- An |lpeg| pattern. Only changes to files and directories whose full path does 24 --- not match the pattern will be reported. Matches against both files and 25 --- directories. When nil, matches nothing. 26 --- @field exclude_pattern? vim.lpeg.Pattern 27 28 --- @alias vim._watch.Callback fun(path: string, change_type: vim._watch.FileChangeType) 29 30 --- @class vim._watch.watch.Opts : vim._watch.Opts 31 --- @field uvflags? uv.fs_event_start.flags 32 33 --- Decides if `path` should be skipped. 34 --- 35 --- @param path string 36 --- @param opts? vim._watch.Opts 37 local function skip(path, opts) 38 if not opts then 39 return false 40 end 41 42 if opts.include_pattern and opts.include_pattern:match(path) == nil then 43 return true 44 end 45 46 if opts.exclude_pattern and opts.exclude_pattern:match(path) ~= nil then 47 return true 48 end 49 50 return false 51 end 52 53 --- Initializes and starts a |uv_fs_event_t| 54 --- 55 --- @param path string The path to watch 56 --- @param opts vim._watch.watch.Opts? Additional options: 57 --- - uvflags (table|nil) 58 --- Same flags as accepted by |uv.fs_event_start()| 59 --- @param callback vim._watch.Callback Callback for new events 60 --- @return fun() cancel Stops the watcher 61 function M.watch(path, opts, callback) 62 vim.validate('path', path, 'string') 63 vim.validate('opts', opts, 'table', true) 64 vim.validate('callback', callback, 'function') 65 66 opts = opts or {} 67 68 path = vim.fs.normalize(path) 69 local uvflags = opts and opts.uvflags or {} 70 local handle = assert(uv.new_fs_event()) 71 72 local watching_dir = (uv.fs_stat(path) or {}).type == 'directory' 73 74 local _, start_err, start_errname = handle:start(path, uvflags, function(err, filename, events) 75 assert(not err, err) 76 local fullpath = path 77 if filename and watching_dir then 78 fullpath = vim.fs.normalize(vim.fs.joinpath(fullpath, filename)) 79 end 80 81 if skip(fullpath, opts) then 82 return 83 end 84 85 --- @type vim._watch.FileChangeType 86 local change_type 87 if events.rename then 88 local _, staterr, staterrname = uv.fs_stat(fullpath) 89 if staterrname == 'ENOENT' then 90 change_type = M.FileChangeType.Deleted 91 else 92 assert(not staterr, staterr) 93 change_type = M.FileChangeType.Created 94 end 95 elseif events.change then 96 change_type = M.FileChangeType.Changed 97 end 98 callback(fullpath, change_type) 99 end) 100 101 if start_err then 102 if start_errname == 'ENOENT' then 103 -- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path. 104 -- This is mostly a placeholder until we have `nvim_log` API. 105 vim.notify_once(('watch.watch: %s'):format(start_err), vim.log.levels.INFO) 106 end 107 handle:close() 108 -- TODO(justinmk): log important errors once we have `nvim_log` API. 109 return function() end 110 end 111 112 return function() 113 local _, stop_err = handle:stop() 114 assert(not stop_err, stop_err) 115 local is_closing, close_err = handle:is_closing() 116 assert(not close_err, close_err) 117 if not is_closing then 118 handle:close() 119 end 120 end 121 end 122 123 --- Initializes and starts a |uv_fs_event_t| recursively watching every directory underneath the 124 --- directory at path. 125 --- 126 --- @param path string The path to watch. Must refer to a directory. 127 --- @param opts vim._watch.Opts? Additional options 128 --- @param callback vim._watch.Callback Callback for new events 129 --- @return fun() cancel Stops the watcher 130 function M.watchdirs(path, opts, callback) 131 vim.validate('path', path, 'string') 132 vim.validate('opts', opts, 'table', true) 133 vim.validate('callback', callback, 'function') 134 135 opts = opts or {} 136 local debounce = opts.debounce or 500 137 138 ---@type table<string, uv.uv_fs_event_t> handle by fullpath 139 local handles = {} 140 141 local timer = assert(uv.new_timer()) 142 143 --- Map of file path to boolean indicating if the file has been changed 144 --- at some point within the debounce cycle. 145 --- @type table<string, boolean> 146 local filechanges = {} 147 148 local process_changes --- @type fun() 149 150 --- @param filepath string 151 --- @return uv.fs_event_start.callback 152 local function create_on_change(filepath) 153 return function(err, filename, events) 154 assert(not err, err) 155 local fullpath = vim.fs.joinpath(filepath, filename) 156 if skip(fullpath, opts) then 157 return 158 end 159 160 if not filechanges[fullpath] then 161 filechanges[fullpath] = events.change or false 162 end 163 timer:start(debounce, 0, process_changes) 164 end 165 end 166 167 process_changes = function() 168 -- Since the callback is debounced it may have also been deleted later on 169 -- so we always need to check the existence of the file: 170 -- stat succeeds, changed=true -> Changed 171 -- stat succeeds, changed=false -> Created 172 -- stat fails -> Removed 173 for fullpath, changed in pairs(filechanges) do 174 uv.fs_stat(fullpath, function(_, stat) 175 ---@type vim._watch.FileChangeType 176 local change_type 177 if stat then 178 change_type = changed and M.FileChangeType.Changed or M.FileChangeType.Created 179 if stat.type == 'directory' then 180 local handle = handles[fullpath] 181 if not handle then 182 handle = assert(uv.new_fs_event()) 183 handles[fullpath] = handle 184 handle:start(fullpath, {}, create_on_change(fullpath)) 185 end 186 end 187 else 188 change_type = M.FileChangeType.Deleted 189 local handle = handles[fullpath] 190 if handle then 191 if not handle:is_closing() then 192 handle:close() 193 end 194 handles[fullpath] = nil 195 end 196 end 197 callback(fullpath, change_type) 198 end) 199 end 200 filechanges = {} 201 end 202 203 local root_handle = assert(uv.new_fs_event()) 204 handles[path] = root_handle 205 local _, start_err, start_errname = root_handle:start(path, {}, create_on_change(path)) 206 207 if start_err then 208 if start_errname == 'ENOENT' then 209 -- Server may send "workspace/didChangeWatchedFiles" with nonexistent `baseUri` path. 210 -- This is mostly a placeholder until we have `nvim_log` API. 211 vim.notify_once(('watch.watchdirs: %s'):format(start_err), vim.log.levels.INFO) 212 end 213 -- TODO(justinmk): log important errors once we have `nvim_log` API. 214 215 -- Continue. vim.fs.dir() will return nothing, so the code below is harmless. 216 end 217 218 --- "640K ought to be enough for anyone" 219 --- Who has folders this deep? 220 local max_depth = 100 221 222 for name, type in vim.fs.dir(path, { depth = max_depth }) do 223 if type == 'directory' then 224 local filepath = vim.fs.joinpath(path, name) 225 if not skip(filepath, opts) then 226 local handle = assert(uv.new_fs_event()) 227 handles[filepath] = handle 228 handle:start(filepath, {}, create_on_change(filepath)) 229 end 230 end 231 end 232 233 local function cancel() 234 for fullpath, handle in pairs(handles) do 235 if not handle:is_closing() then 236 handle:close() 237 end 238 handles[fullpath] = nil 239 end 240 timer:stop() 241 timer:close() 242 end 243 244 return cancel 245 end 246 247 --- @param data string 248 --- @param opts vim._watch.Opts? 249 --- @param callback vim._watch.Callback 250 local function on_inotifywait_output(data, opts, callback) 251 local d = vim.split(data, '%s+') 252 253 -- only consider the last reported event 254 local path, event, file = d[1], d[2], d[#d] 255 local fullpath = vim.fs.joinpath(path, file) 256 257 if skip(fullpath, opts) then 258 return 259 end 260 261 --- @type integer 262 local change_type 263 264 if event == 'CREATE' then 265 change_type = M.FileChangeType.Created 266 elseif event == 'DELETE' then 267 change_type = M.FileChangeType.Deleted 268 elseif event == 'MODIFY' then 269 change_type = M.FileChangeType.Changed 270 elseif event == 'MOVED_FROM' then 271 change_type = M.FileChangeType.Deleted 272 elseif event == 'MOVED_TO' then 273 change_type = M.FileChangeType.Created 274 end 275 276 if change_type then 277 callback(fullpath, change_type) 278 end 279 end 280 281 --- @param path string The path to watch. Must refer to a directory. 282 --- @param opts vim._watch.Opts? 283 --- @param callback vim._watch.Callback Callback for new events 284 --- @return fun() cancel Stops the watcher 285 function M.inotify(path, opts, callback) 286 local obj = vim.system({ 287 'inotifywait', 288 '--quiet', -- suppress startup messages 289 '--no-dereference', -- don't follow symlinks 290 '--monitor', -- keep listening for events forever 291 '--recursive', 292 '--event', 293 'create', 294 '--event', 295 'delete', 296 '--event', 297 'modify', 298 '--event', 299 'move', 300 string.format('@%s/.git', path), -- ignore git directory 301 path, 302 }, { 303 stderr = function(err, data) 304 if err then 305 error(err) 306 end 307 308 if data and #vim.trim(data) > 0 then 309 vim.schedule(function() 310 if vim.fn.has('linux') == 1 and vim.startswith(data, 'Failed to watch') then 311 data = 'inotify(7) limit reached, see :h inotify-limitations for more info.' 312 end 313 314 vim.notify('inotify: ' .. data, vim.log.levels.ERROR) 315 end) 316 end 317 end, 318 stdout = function(err, data) 319 if err then 320 error(err) 321 end 322 323 for line in vim.gsplit(data or '', '\n', { plain = true, trimempty = true }) do 324 on_inotifywait_output(line, opts, callback) 325 end 326 end, 327 -- --latency is locale dependent but tostring() isn't and will always have '.' as decimal point. 328 env = { LC_NUMERIC = 'C' }, 329 }) 330 331 return function() 332 obj:kill(2) 333 end 334 end 335 336 return M