_watchfiles.lua 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. local bit = require('bit')
  2. local glob = vim.glob
  3. local watch = vim._watch
  4. local protocol = require('vim.lsp.protocol')
  5. local ms = protocol.Methods
  6. local lpeg = vim.lpeg
  7. local M = {}
  8. if vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1 then
  9. M._watchfunc = watch.watch
  10. elseif vim.fn.executable('inotifywait') == 1 then
  11. M._watchfunc = watch.inotify
  12. else
  13. M._watchfunc = watch.watchdirs
  14. end
  15. ---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function
  16. local cancels = vim.defaulttable()
  17. local queue_timeout_ms = 100
  18. ---@type table<integer, uv.uv_timer_t> client id -> libuv timer which will send queued changes at its timeout
  19. local queue_timers = {}
  20. ---@type table<integer, lsp.FileEvent[]> client id -> set of queued changes to send in a single LSP notification
  21. local change_queues = {}
  22. ---@type table<integer, table<string, lsp.FileChangeType>> client id -> URI -> last type of change processed
  23. --- Used to prune consecutive events of the same type for the same file
  24. local change_cache = vim.defaulttable()
  25. ---@type table<vim._watch.FileChangeType, lsp.FileChangeType>
  26. local to_lsp_change_type = {
  27. [watch.FileChangeType.Created] = protocol.FileChangeType.Created,
  28. [watch.FileChangeType.Changed] = protocol.FileChangeType.Changed,
  29. [watch.FileChangeType.Deleted] = protocol.FileChangeType.Deleted,
  30. }
  31. --- Default excludes the same as VSCode's `files.watcherExclude` setting.
  32. --- https://github.com/microsoft/vscode/blob/eef30e7165e19b33daa1e15e92fa34ff4a5df0d3/src/vs/workbench/contrib/files/browser/files.contribution.ts#L261
  33. ---@type vim.lpeg.Pattern parsed Lpeg pattern
  34. M._poll_exclude_pattern = glob.to_lpeg('**/.git/{objects,subtree-cache}/**')
  35. + glob.to_lpeg('**/node_modules/*/**')
  36. + glob.to_lpeg('**/.hg/store/**')
  37. --- Registers the workspace/didChangeWatchedFiles capability dynamically.
  38. ---
  39. ---@param reg lsp.Registration LSP Registration object.
  40. ---@param client_id integer Client ID.
  41. function M.register(reg, client_id)
  42. local client = assert(vim.lsp.get_client_by_id(client_id), 'Client must be running')
  43. -- Ill-behaved servers may not honor the client capability and try to register
  44. -- anyway, so ignore requests when the user has opted out of the feature.
  45. local has_capability =
  46. vim.tbl_get(client.capabilities, 'workspace', 'didChangeWatchedFiles', 'dynamicRegistration')
  47. if not has_capability or not client.workspace_folders then
  48. return
  49. end
  50. local register_options = reg.registerOptions --[[@as lsp.DidChangeWatchedFilesRegistrationOptions]]
  51. ---@type table<string, {pattern: vim.lpeg.Pattern, kind: lsp.WatchKind}[]> by base_dir
  52. local watch_regs = vim.defaulttable()
  53. for _, w in ipairs(register_options.watchers) do
  54. local kind = w.kind
  55. or (protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete)
  56. local glob_pattern = w.globPattern
  57. if type(glob_pattern) == 'string' then
  58. local pattern = glob.to_lpeg(glob_pattern)
  59. if not pattern then
  60. error('Cannot parse pattern: ' .. glob_pattern)
  61. end
  62. for _, folder in ipairs(client.workspace_folders) do
  63. local base_dir = vim.uri_to_fname(folder.uri)
  64. table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
  65. end
  66. else
  67. local base_uri = glob_pattern.baseUri
  68. local uri = type(base_uri) == 'string' and base_uri or base_uri.uri
  69. local base_dir = vim.uri_to_fname(uri)
  70. local pattern = glob.to_lpeg(glob_pattern.pattern)
  71. if not pattern then
  72. error('Cannot parse pattern: ' .. glob_pattern.pattern)
  73. end
  74. pattern = lpeg.P(base_dir .. '/') * pattern
  75. table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
  76. end
  77. end
  78. ---@param base_dir string
  79. local callback = function(base_dir)
  80. return function(fullpath, change_type)
  81. local registrations = watch_regs[base_dir]
  82. for _, w in ipairs(registrations) do
  83. local lsp_change_type = assert(
  84. to_lsp_change_type[change_type],
  85. 'Must receive change type Created, Changed or Deleted'
  86. )
  87. -- e.g. match kind with Delete bit (0b0100) to Delete change_type (3)
  88. local kind_mask = bit.lshift(1, lsp_change_type - 1)
  89. local change_type_match = bit.band(w.kind, kind_mask) == kind_mask
  90. if w.pattern:match(fullpath) ~= nil and change_type_match then
  91. ---@type lsp.FileEvent
  92. local change = {
  93. uri = vim.uri_from_fname(fullpath),
  94. type = lsp_change_type,
  95. }
  96. local last_type = change_cache[client_id][change.uri]
  97. if last_type ~= change.type then
  98. change_queues[client_id] = change_queues[client_id] or {}
  99. table.insert(change_queues[client_id], change)
  100. change_cache[client_id][change.uri] = change.type
  101. end
  102. if not queue_timers[client_id] then
  103. queue_timers[client_id] = vim.defer_fn(function()
  104. ---@type lsp.DidChangeWatchedFilesParams
  105. local params = {
  106. changes = change_queues[client_id],
  107. }
  108. client:notify(ms.workspace_didChangeWatchedFiles, params)
  109. queue_timers[client_id] = nil
  110. change_queues[client_id] = nil
  111. change_cache[client_id] = nil
  112. end, queue_timeout_ms)
  113. end
  114. break -- if an event matches multiple watchers, only send one notification
  115. end
  116. end
  117. end
  118. end
  119. for base_dir, watches in pairs(watch_regs) do
  120. local include_pattern = vim.iter(watches):fold(lpeg.P(false), function(acc, w)
  121. return acc + w.pattern
  122. end)
  123. table.insert(
  124. cancels[client_id][reg.id],
  125. M._watchfunc(base_dir, {
  126. uvflags = {
  127. recursive = true,
  128. },
  129. -- include_pattern will ensure the pattern from *any* watcher definition for the
  130. -- base_dir matches. This first pass prevents polling for changes to files that
  131. -- will never be sent to the LSP server. A second pass in the callback is still necessary to
  132. -- match a *particular* pattern+kind pair.
  133. include_pattern = include_pattern,
  134. exclude_pattern = M._poll_exclude_pattern,
  135. }, callback(base_dir))
  136. )
  137. end
  138. end
  139. --- Unregisters the workspace/didChangeWatchedFiles capability dynamically.
  140. ---
  141. ---@param unreg lsp.Unregistration LSP Unregistration object.
  142. ---@param client_id integer Client ID.
  143. function M.unregister(unreg, client_id)
  144. local client_cancels = cancels[client_id]
  145. local reg_cancels = client_cancels[unreg.id]
  146. while #reg_cancels > 0 do
  147. table.remove(reg_cancels)()
  148. end
  149. client_cancels[unreg.id] = nil
  150. if not next(cancels[client_id]) then
  151. cancels[client_id] = nil
  152. end
  153. end
  154. --- @param client_id integer
  155. function M.cancel(client_id)
  156. for _, reg_cancels in pairs(cancels[client_id]) do
  157. for _, cancel in pairs(reg_cancels) do
  158. cancel()
  159. end
  160. end
  161. end
  162. return M