_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