neovim

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

_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