_watch.lua 9.7 KB

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