neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

_watchfiles.lua (6948B)


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