From 1740d51eded3cd9b8244470b3e79d467598301ed Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Wed, 15 Apr 2026 22:27:44 +0800 Subject: [PATCH] feat(lsp): highlight foldtext via treesitter #38789 Problem: To support `collapsedText`, which allows the LSP server to determine the content of the foldtext, we provided `vim.lsp.foldtext()`. However, such content does not have highlighting. Solution Treat the filetype of `collapsedText` as the filetype of the corresponding buffer and use tree-sitter to highlight it. --- runtime/doc/lsp.txt | 7 +- runtime/doc/news.txt | 2 + runtime/lua/vim/lsp.lua | 7 +- runtime/lua/vim/lsp/_folding_range.lua | 115 +++++++++++++++++- .../plugin/lsp/folding_range_spec.lua | 23 ++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 333cd6ed81..b4d0620234 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -1204,10 +1204,15 @@ foldexpr({lnum}) *vim.lsp.foldexpr()* Parameters: ~ • {lnum} (`integer`) line number -foldtext() *vim.lsp.foldtext()* +foldtext({lnum}) *vim.lsp.foldtext()* Provides a `foldtext` function that shows the `collapsedText` retrieved, defaults to the first folded line if `collapsedText` is not provided. + The displayed foldtext will be highlighted via treesitter. + + Parameters: ~ + • {lnum} (`integer?`) line number (default: current fold start) + formatexpr({opts}) *vim.lsp.formatexpr()* Provides an interface between the built-in client and a `formatexpr` function. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index dac678ed3c..aa255ce9ed 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -138,6 +138,8 @@ LSP • LSP capabilities: • Completion supports `CompletionItem.preselect` if 'completeopt' has "preselect". https://microsoft.github.io/language-server-protocol/specification/#completionClientCapabilities + • `textDocument/foldingRange` |vim.lsp.foldtext()| highlights collapsed text. + https://microsoft.github.io/language-server-protocol/specification/#textDocument_foldingRange • |vim.lsp.buf.declaration()|, |vim.lsp.buf.definition()|, |vim.lsp.buf.definition()|, and |vim.lsp.buf.implementation()| now follows 'switchbuf'. • Support for nested snippets. diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index facac9e2c3..6cbe98b6eb 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -1506,8 +1506,11 @@ end --- Provides a `foldtext` function that shows the `collapsedText` retrieved, --- defaults to the first folded line if `collapsedText` is not provided. -function lsp.foldtext() - return vim.lsp._folding_range.foldtext() +--- +--- The displayed foldtext will be highlighted via treesitter. +---@param lnum? integer line number (default: current fold start) +function lsp.foldtext(lnum) + return vim.lsp._folding_range.foldtext(lnum) end ---@deprecated Use |vim.lsp.get_client_by_id()| instead. diff --git a/runtime/lua/vim/lsp/_folding_range.lua b/runtime/lua/vim/lsp/_folding_range.lua index 39998bb626..b007ab58ff 100644 --- a/runtime/lua/vim/lsp/_folding_range.lua +++ b/runtime/lua/vim/lsp/_folding_range.lua @@ -20,6 +20,9 @@ local Capability = require('vim.lsp._capability') --- `TextDocument` version this `state` corresponds to. ---@field version? integer --- +--- treesitter language of the buffer, used for foldtext highlights. +---@field lang? string +--- --- Never use this directly, `evaluate()` the cached foldinfo --- then use on demand via `row_*` fields. --- @@ -34,6 +37,9 @@ local Capability = require('vim.lsp._capability') --- --- Index in the form of start_row -> collapsed_text ---@field row_text table +--- +--- Index in the form of start_row -> [text, highlight[]?][] +---@field row_virt_text table local State = { name = 'folding_range', method = 'textDocument/foldingRange', @@ -86,6 +92,7 @@ function State:evaluate() self.row_level = row_level self.row_kinds = row_kinds self.row_text = row_text + self.row_virt_text = {} end --- Force `foldexpr()` to be re-evaluated, without opening folds. @@ -182,9 +189,11 @@ function State:refresh(client) end function State:reset() + self.lang = vim.treesitter.language.get_lang(vim.bo[self.bufnr].filetype) self.row_level = {} self.row_kinds = {} self.row_text = {} + self.row_virt_text = {} end --- Initialize `state` and event hooks, then request folding ranges. @@ -250,6 +259,13 @@ function State:new(bufnr) end end, }) + api.nvim_create_autocmd('FileType', { + group = self.augroup, + buffer = bufnr, + callback = function() + self:reset() + end, + }) return self end @@ -321,16 +337,103 @@ function M.foldclose(kind, winid) end) end ----@return string -function M.foldtext() +--- Split `line` into highlighted virt_text chunks from `spans`. +--- +---@param line string +---@param spans [integer, integer, string][] [start_col, end_col, highlight] +---@return [string, string[]?][] [text, highlight[]?][] +local function spans_to_virt_text(line, spans) + local boundaries = { 0, #line } + for _, span in ipairs(spans) do + boundaries[#boundaries + 1] = span[1] + boundaries[#boundaries + 1] = span[2] + end + table.sort(boundaries) + + local virt_text = {} ---@type [string, string[]][] + local last_b = -1 + for _, b in ipairs(boundaries) do + if b > last_b then + if last_b >= 0 then + local start_col = last_b + local end_col = b + local text = line:sub(start_col + 1, end_col) + local highlight = {} ---@type string[] + for _, span in ipairs(spans) do + if span[1] <= start_col and end_col <= span[2] then + if highlight[#highlight] ~= span[3] then + highlight[#highlight + 1] = span[3] + end + end + end + if #highlight == 0 then + virt_text[#virt_text + 1] = { text } + else + virt_text[#virt_text + 1] = { text, highlight } + end + end + last_b = b + end + end + + return virt_text +end + +--- Return foldtext highlighted via treesitter, if available. +--- +---@return string|[string, string[]?][] +---@param lnum? integer +function M.foldtext(lnum) + lnum = lnum or vim.v.foldstart local bufnr = api.nvim_get_current_buf() - local lnum = vim.v.foldstart local row = lnum - 1 local state = State.active[bufnr] - if state and state.row_text[row] then - return state.row_text[row] + local lang = state and state.lang + local line = vim.fn.getline(lnum) + if not lang then + return line + end ---@cast state -nil + + local virt_text = state.row_virt_text[row] + if virt_text then + return virt_text end - return vim.fn.getline(lnum) + + line = state.row_text[row] or line + local ok, parser = pcall(function() + local parser = vim.treesitter.get_string_parser(line, lang) + parser:parse(true) + return parser + end) + if not ok then + return line + end + + --- Collect treesitter highlight spans for the foldtext. + --- [start_col, end_col, highlight] + ---@type [integer, integer, string][] + local spans = {} + parser:for_each_tree(function(tstree, tree) + local query = vim.treesitter.query.get(tree:lang(), 'highlights') + if query then + for capture, node in query:iter_captures(tstree:root(), line) do + local name = query.captures[capture] + local _, start_col, _, end_col = node:range() + if name:match('^[^_]') then + spans[#spans + 1] = { + start_col, + end_col, + ('@%s.%s'):format(name, tree:lang()), + } + end + end + end + end) + + virt_text = spans_to_virt_text(line, spans) + state.row_virt_text[row] = virt_text + + return virt_text end ---@param lnum? integer diff --git a/test/functional/plugin/lsp/folding_range_spec.lua b/test/functional/plugin/lsp/folding_range_spec.lua index d82b98b045..48f2d59148 100644 --- a/test/functional/plugin/lsp/folding_range_spec.lua +++ b/test/functional/plugin/lsp/folding_range_spec.lua @@ -499,6 +499,29 @@ static int foldLevel(linenr_T lnum) ]], }) end) + + it('shows the foldtext by virt line', function() + command([[set filetype=c]]) + eq( + { + { ' ' }, + { 'if', { '@keyword.conditional.c' } }, + { ' ' }, + { '(', { '@punctuation.bracket.c' } }, + { '!', { '@operator.c' } }, + { 'hasAnyFolding', { '@variable.c', '@function.call.c' } }, + { '(', { '@punctuation.bracket.c' } }, + { 'curwin', { '@variable.c' } }, + { ')', { '@punctuation.bracket.c' } }, + { ')', { '@punctuation.bracket.c' } }, + { ' ' }, + { '{', { '@punctuation.bracket.c' } }, + }, + exec_lua(function() + return vim.lsp.foldtext(16) + end) + ) + end) end) describe('foldclose()', function()