neovim

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

_folding_range.lua (10584B)


      1 local util = require('vim.lsp.util')
      2 local log = require('vim.lsp.log')
      3 local api = vim.api
      4 
      5 ---@type table<lsp.FoldingRangeKind, true>
      6 local supported_fold_kinds = {
      7  ['comment'] = true,
      8  ['imports'] = true,
      9  ['region'] = true,
     10 }
     11 
     12 local M = {}
     13 
     14 local Capability = require('vim.lsp._capability')
     15 
     16 ---@class (private) vim.lsp.folding_range.State : vim.lsp.Capability
     17 ---
     18 ---@field active table<integer, vim.lsp.folding_range.State?>
     19 ---
     20 --- `TextDocument` version this `state` corresponds to.
     21 ---@field version? integer
     22 ---
     23 --- Never use this directly, `evaluate()` the cached foldinfo
     24 --- then use on demand via `row_*` fields.
     25 ---
     26 --- Index In the form of client_id -> ranges
     27 ---@field client_state table<integer, lsp.FoldingRange[]?>
     28 ---
     29 --- Index in the form of row -> [foldlevel, mark]
     30 ---@field row_level table<integer, [integer, ">" | "<"?]?>
     31 ---
     32 --- Index in the form of start_row -> kinds
     33 ---@field row_kinds table<integer, table<lsp.FoldingRangeKind, true?>?>>
     34 ---
     35 --- Index in the form of start_row -> collapsed_text
     36 ---@field row_text table<integer, string?>
     37 local State = {
     38  name = 'folding_range',
     39  method = 'textDocument/foldingRange',
     40  active = {},
     41 }
     42 State.__index = State
     43 setmetatable(State, Capability)
     44 Capability.all[State.name] = State
     45 
     46 --- Re-evaluate the cached foldinfo in the buffer.
     47 function State:evaluate()
     48  ---@type table<integer, [integer, ">" | "<"?]?>
     49  local row_level = {}
     50  ---@type table<integer, table<lsp.FoldingRangeKind, true?>?>>
     51  local row_kinds = {}
     52  ---@type table<integer, string?>
     53  local row_text = {}
     54 
     55  for client_id, ranges in pairs(self.client_state) do
     56    for _, range in ipairs(ranges) do
     57      local start_row = range.startLine
     58      local end_row = range.endLine
     59      -- Ignore zero-length or invalid folds
     60      if start_row < end_row then
     61        row_text[start_row] = range.collapsedText
     62 
     63        local kind = range.kind
     64        if kind then
     65          -- Ignore unsupported fold kinds.
     66          if supported_fold_kinds[kind] then
     67            local kinds = row_kinds[start_row] or {}
     68            kinds[kind] = true
     69            row_kinds[start_row] = kinds
     70          else
     71            log.info(('Unknown fold kind "%s" from client %d'):format(kind, client_id))
     72          end
     73        end
     74 
     75        for row = start_row, end_row do
     76          local level = row_level[row] or { 0 }
     77          level[1] = level[1] + 1
     78          row_level[row] = level
     79        end
     80        row_level[start_row][2] = '>'
     81        row_level[end_row][2] = '<'
     82      end
     83    end
     84  end
     85 
     86  self.row_level = row_level
     87  self.row_kinds = row_kinds
     88  self.row_text = row_text
     89 end
     90 
     91 --- Force `foldexpr()` to be re-evaluated, without opening folds.
     92 ---@param bufnr integer
     93 local function foldupdate(bufnr)
     94  for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
     95    local wininfo = vim.fn.getwininfo(winid)[1]
     96    if wininfo and wininfo.tabnr == vim.fn.tabpagenr() then
     97      if vim.wo[winid].foldmethod == 'expr' then
     98        vim._foldupdate(winid, 0, api.nvim_buf_line_count(bufnr))
     99      end
    100    end
    101  end
    102 end
    103 
    104 --- Whether `foldupdate()` is scheduled for the buffer with `bufnr`.
    105 ---
    106 --- Index in the form of bufnr -> true?
    107 ---@type table<integer, true?>
    108 local scheduled_foldupdate = {}
    109 
    110 --- Schedule `foldupdate()` after leaving insert mode.
    111 ---@param bufnr integer
    112 local function schedule_foldupdate(bufnr)
    113  if not scheduled_foldupdate[bufnr] then
    114    scheduled_foldupdate[bufnr] = true
    115    api.nvim_create_autocmd('InsertLeave', {
    116      buffer = bufnr,
    117      once = true,
    118      callback = function()
    119        foldupdate(bufnr)
    120        scheduled_foldupdate[bufnr] = nil
    121      end,
    122    })
    123  end
    124 end
    125 
    126 ---@param results table<integer,{err: lsp.ResponseError?, result: lsp.FoldingRange[]?}>
    127 ---@param ctx lsp.HandlerContext
    128 function State:multi_handler(results, ctx)
    129  -- Handling responses from outdated buffer only causes performance overhead.
    130  if util.buf_versions[self.bufnr] ~= ctx.version then
    131    return
    132  end
    133 
    134  for client_id, result in pairs(results) do
    135    if result.err then
    136      log.error(result.err)
    137    else
    138      self.client_state[client_id] = result.result
    139    end
    140  end
    141  self.version = ctx.version
    142 
    143  self:evaluate()
    144  if api.nvim_get_mode().mode:match('^i') then
    145    -- `foldUpdate()` is guarded in insert mode.
    146    schedule_foldupdate(self.bufnr)
    147  else
    148    foldupdate(self.bufnr)
    149  end
    150 end
    151 
    152 ---@param err lsp.ResponseError?
    153 ---@param result lsp.FoldingRange[]?
    154 ---@param ctx lsp.HandlerContext, config?: table
    155 function State:handler(err, result, ctx)
    156  self:multi_handler({ [ctx.client_id] = { err = err, result = result } }, ctx)
    157 end
    158 
    159 --- Request `textDocument/foldingRange` from the server.
    160 --- `foldupdate()` is scheduled once after the request is completed.
    161 ---@param client? vim.lsp.Client The client whose server supports `foldingRange`.
    162 function State:refresh(client)
    163  ---@type lsp.FoldingRangeParams
    164  local params = { textDocument = util.make_text_document_params(self.bufnr) }
    165 
    166  if client then
    167    client:request('textDocument/foldingRange', params, function(...)
    168      self:handler(...)
    169    end, self.bufnr)
    170    return
    171  end
    172 
    173  if
    174    not next(vim.lsp.get_clients({ bufnr = self.bufnr, method = 'textDocument/foldingRange' }))
    175  then
    176    return
    177  end
    178 
    179  vim.lsp.buf_request_all(self.bufnr, 'textDocument/foldingRange', params, function(...)
    180    self:multi_handler(...)
    181  end)
    182 end
    183 
    184 function State:reset()
    185  self.row_level = {}
    186  self.row_kinds = {}
    187  self.row_text = {}
    188 end
    189 
    190 --- Initialize `state` and event hooks, then request folding ranges.
    191 ---@param bufnr integer
    192 ---@return vim.lsp.folding_range.State
    193 function State:new(bufnr)
    194  self = Capability.new(self, bufnr)
    195  self:reset()
    196 
    197  api.nvim_buf_attach(bufnr, false, {
    198    -- Reset `bufstate` and request folding ranges.
    199    on_reload = function()
    200      local state = State.active[bufnr]
    201      if state then
    202        state:reset()
    203        state:refresh()
    204      end
    205    end,
    206    --- Sync changed rows with their previous foldlevels before applying new ones.
    207    on_bytes = function(_, _, _, start_row, _, _, old_row, _, _, new_row, _, _)
    208      local state = State.active[bufnr]
    209      if state == nil then
    210        return true
    211      end
    212      local row_level = state.row_level
    213      if next(row_level) == nil then
    214        return
    215      end
    216      local row = new_row - old_row
    217      if row > 0 then
    218        vim._list_insert(row_level, start_row, start_row + math.abs(row) - 1, { -1 })
    219        -- If the previous row ends a fold,
    220        -- Nvim treats the first row after consecutive `-1`s as a new fold start,
    221        -- which is not the desired behavior.
    222        local prev_level = row_level[start_row - 1]
    223        if prev_level and prev_level[2] == '<' then
    224          row_level[start_row] = { prev_level[1] - 1 }
    225        end
    226      elseif row < 0 then
    227        vim._list_remove(row_level, start_row, start_row + math.abs(row) - 1)
    228      end
    229    end,
    230  })
    231  api.nvim_create_autocmd('LspNotify', {
    232    group = self.augroup,
    233    buffer = bufnr,
    234    callback = function(args)
    235      local client = assert(vim.lsp.get_client_by_id(args.data.client_id))
    236      if
    237        client:supports_method('textDocument/foldingRange', bufnr)
    238        and (
    239          args.data.method == 'textDocument/didChange'
    240          or args.data.method == 'textDocument/didOpen'
    241        )
    242      then
    243        self:refresh(client)
    244      end
    245    end,
    246  })
    247  api.nvim_create_autocmd('OptionSet', {
    248    group = self.augroup,
    249    pattern = 'foldexpr',
    250    callback = function()
    251      if vim.v.option_type == 'global' or api.nvim_get_current_buf() == bufnr then
    252        vim.lsp._capability.enable('folding_range', false, { bufnr = bufnr })
    253      end
    254    end,
    255  })
    256 
    257  return self
    258 end
    259 
    260 function State:destroy()
    261  api.nvim_del_augroup_by_id(self.augroup)
    262  State.active[self.bufnr] = nil
    263 end
    264 
    265 ---@param client_id integer
    266 function State:on_attach(client_id)
    267  self.client_state[client_id] = {}
    268  self:refresh(vim.lsp.get_client_by_id(client_id))
    269 end
    270 
    271 ---@params client_id integer
    272 function State:on_detach(client_id)
    273  self.client_state[client_id] = nil
    274  self:evaluate()
    275  foldupdate(self.bufnr)
    276 end
    277 
    278 ---@param kind lsp.FoldingRangeKind
    279 ---@param winid integer
    280 function State:foldclose(kind, winid)
    281  vim._with({ win = winid }, function()
    282    local bufnr = api.nvim_win_get_buf(winid)
    283    local row_kinds = State.active[bufnr].row_kinds
    284    -- Reverse traverse to ensure that the smallest ranges are closed first.
    285    for row = api.nvim_buf_line_count(bufnr) - 1, 0, -1 do
    286      local kinds = row_kinds[row]
    287      if kinds and kinds[kind] then
    288        vim.cmd(row + 1 .. 'foldclose')
    289      end
    290    end
    291  end)
    292 end
    293 
    294 ---@param kind lsp.FoldingRangeKind
    295 ---@param winid? integer
    296 function M.foldclose(kind, winid)
    297  vim.validate('kind', kind, 'string')
    298  vim.validate('winid', winid, 'number', true)
    299 
    300  winid = winid or api.nvim_get_current_win()
    301  local bufnr = api.nvim_win_get_buf(winid)
    302  local state = State.active[bufnr]
    303  if not state then
    304    return
    305  end
    306 
    307  -- Schedule `foldclose()` if the buffer is not up-to-date.
    308  if state.version == util.buf_versions[bufnr] then
    309    state:foldclose(kind, winid)
    310    return
    311  end
    312 
    313  if not next(vim.lsp.get_clients({ bufnr = bufnr, method = 'textDocument/foldingRange' })) then
    314    return
    315  end
    316  ---@type lsp.FoldingRangeParams
    317  local params = { textDocument = util.make_text_document_params(bufnr) }
    318  vim.lsp.buf_request_all(bufnr, 'textDocument/foldingRange', params, function(...)
    319    state:multi_handler(...)
    320    -- Ensure this buffer stays as the current buffer after the async request
    321    if api.nvim_win_get_buf(winid) == bufnr then
    322      state:foldclose(kind, winid)
    323    end
    324  end)
    325 end
    326 
    327 ---@return string
    328 function M.foldtext()
    329  local bufnr = api.nvim_get_current_buf()
    330  local lnum = vim.v.foldstart
    331  local row = lnum - 1
    332  local state = State.active[bufnr]
    333  if state and state.row_text[row] then
    334    return state.row_text[row]
    335  end
    336  return vim.fn.getline(lnum)
    337 end
    338 
    339 ---@param lnum? integer
    340 ---@return string level
    341 function M.foldexpr(lnum)
    342  local bufnr = api.nvim_get_current_buf()
    343  if not vim.lsp._capability.is_enabled('folding_range', { bufnr = bufnr }) then
    344    -- `foldexpr` lead to a textlock, so any further operations need to be scheduled.
    345    vim.schedule(function()
    346      if api.nvim_buf_is_valid(bufnr) then
    347        vim.lsp._capability.enable('folding_range', true, { bufnr = bufnr })
    348      end
    349    end)
    350  end
    351 
    352  local state = State.active[bufnr]
    353  if not state then
    354    return '0'
    355  end
    356  local row = (lnum or vim.v.lnum) - 1
    357  local level = state.row_level[row]
    358  return level and (level[2] or '') .. (level[1] or '0') or '0'
    359 end
    360 
    361 return M