aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/lsp/_watchfiles.lua
blob: 4711b3cc9b1896e8b9d406ca6f234af74061f23f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
local bit = require('bit')
local glob = vim.glob
local watch = vim._watch
local protocol = require('vim.lsp.protocol')
local ms = protocol.Methods
local lpeg = vim.lpeg

local M = {}

if vim.fn.has('win32') == 1 or vim.fn.has('mac') == 1 then
  M._watchfunc = watch.watch
elseif vim.fn.executable('inotifywait') == 1 then
  M._watchfunc = watch.inotify
else
  M._watchfunc = watch.watchdirs
end

---@type table<integer, table<string, function[]>> client id -> registration id -> cancel function
local cancels = vim.defaulttable()

local queue_timeout_ms = 100
---@type table<integer, uv.uv_timer_t> client id -> libuv timer which will send queued changes at its timeout
local queue_timers = {}
---@type table<integer, lsp.FileEvent[]> client id -> set of queued changes to send in a single LSP notification
local change_queues = {}
---@type table<integer, table<string, lsp.FileChangeType>> client id -> URI -> last type of change processed
--- Used to prune consecutive events of the same type for the same file
local change_cache = vim.defaulttable()

---@type table<vim._watch.FileChangeType, lsp.FileChangeType>
local to_lsp_change_type = {
  [watch.FileChangeType.Created] = protocol.FileChangeType.Created,
  [watch.FileChangeType.Changed] = protocol.FileChangeType.Changed,
  [watch.FileChangeType.Deleted] = protocol.FileChangeType.Deleted,
}

--- Default excludes the same as VSCode's `files.watcherExclude` setting.
--- https://github.com/microsoft/vscode/blob/eef30e7165e19b33daa1e15e92fa34ff4a5df0d3/src/vs/workbench/contrib/files/browser/files.contribution.ts#L261
---@type vim.lpeg.Pattern parsed Lpeg pattern
M._poll_exclude_pattern = glob.to_lpeg('**/.git/{objects,subtree-cache}/**')
  + glob.to_lpeg('**/node_modules/*/**')
  + glob.to_lpeg('**/.hg/store/**')

--- Registers the workspace/didChangeWatchedFiles capability dynamically.
---
---@param reg lsp.Registration LSP Registration object.
---@param client_id integer Client ID.
function M.register(reg, client_id)
  local client = assert(vim.lsp.get_client_by_id(client_id), 'Client must be running')
  -- Ill-behaved servers may not honor the client capability and try to register
  -- anyway, so ignore requests when the user has opted out of the feature.
  local has_capability =
    vim.tbl_get(client.capabilities, 'workspace', 'didChangeWatchedFiles', 'dynamicRegistration')
  if not has_capability or not client.workspace_folders then
    return
  end
  local register_options = reg.registerOptions --[[@as lsp.DidChangeWatchedFilesRegistrationOptions]]
  ---@type table<string, {pattern: vim.lpeg.Pattern, kind: lsp.WatchKind}[]> by base_dir
  local watch_regs = vim.defaulttable()
  for _, w in ipairs(register_options.watchers) do
    local kind = w.kind
      or (protocol.WatchKind.Create + protocol.WatchKind.Change + protocol.WatchKind.Delete)
    local glob_pattern = w.globPattern

    if type(glob_pattern) == 'string' then
      local pattern = glob.to_lpeg(glob_pattern)
      if not pattern then
        error('Cannot parse pattern: ' .. glob_pattern)
      end
      for _, folder in ipairs(client.workspace_folders) do
        local base_dir = vim.uri_to_fname(folder.uri)
        table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
      end
    else
      local base_uri = glob_pattern.baseUri
      local uri = type(base_uri) == 'string' and base_uri or base_uri.uri
      local base_dir = vim.uri_to_fname(uri)
      local pattern = glob.to_lpeg(glob_pattern.pattern)
      if not pattern then
        error('Cannot parse pattern: ' .. glob_pattern.pattern)
      end
      pattern = lpeg.P(base_dir .. '/') * pattern
      table.insert(watch_regs[base_dir], { pattern = pattern, kind = kind })
    end
  end

  ---@param base_dir string
  local callback = function(base_dir)
    return function(fullpath, change_type)
      local registrations = watch_regs[base_dir]
      for _, w in ipairs(registrations) do
        local lsp_change_type = assert(
          to_lsp_change_type[change_type],
          'Must receive change type Created, Changed or Deleted'
        )
        -- e.g. match kind with Delete bit (0b0100) to Delete change_type (3)
        local kind_mask = bit.lshift(1, lsp_change_type - 1)
        local change_type_match = bit.band(w.kind, kind_mask) == kind_mask
        if w.pattern:match(fullpath) ~= nil and change_type_match then
          ---@type lsp.FileEvent
          local change = {
            uri = vim.uri_from_fname(fullpath),
            type = lsp_change_type,
          }

          local last_type = change_cache[client_id][change.uri]
          if last_type ~= change.type then
            change_queues[client_id] = change_queues[client_id] or {}
            table.insert(change_queues[client_id], change)
            change_cache[client_id][change.uri] = change.type
          end

          if not queue_timers[client_id] then
            queue_timers[client_id] = vim.defer_fn(function()
              ---@type lsp.DidChangeWatchedFilesParams
              local params = {
                changes = change_queues[client_id],
              }
              client:notify(ms.workspace_didChangeWatchedFiles, params)
              queue_timers[client_id] = nil
              change_queues[client_id] = nil
              change_cache[client_id] = nil
            end, queue_timeout_ms)
          end

          break -- if an event matches multiple watchers, only send one notification
        end
      end
    end
  end

  for base_dir, watches in pairs(watch_regs) do
    local include_pattern = vim.iter(watches):fold(lpeg.P(false), function(acc, w)
      return acc + w.pattern
    end)

    table.insert(
      cancels[client_id][reg.id],
      M._watchfunc(base_dir, {
        uvflags = {
          recursive = true,
        },
        -- include_pattern will ensure the pattern from *any* watcher definition for the
        -- base_dir matches. This first pass prevents polling for changes to files that
        -- will never be sent to the LSP server. A second pass in the callback is still necessary to
        -- match a *particular* pattern+kind pair.
        include_pattern = include_pattern,
        exclude_pattern = M._poll_exclude_pattern,
      }, callback(base_dir))
    )
  end
end

--- Unregisters the workspace/didChangeWatchedFiles capability dynamically.
---
---@param unreg lsp.Unregistration LSP Unregistration object.
---@param client_id integer Client ID.
function M.unregister(unreg, client_id)
  local client_cancels = cancels[client_id]
  local reg_cancels = client_cancels[unreg.id]
  while #reg_cancels > 0 do
    table.remove(reg_cancels)()
  end
  client_cancels[unreg.id] = nil
  if not next(cancels[client_id]) then
    cancels[client_id] = nil
  end
end

--- @param client_id integer
function M.cancel(client_id)
  for _, reg_cancels in pairs(cancels[client_id]) do
    for _, cancel in pairs(reg_cancels) do
      cancel()
    end
  end
  cancels[client_id] = nil
end

return M