diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 3607d5f74a..5efbbd595c 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2155,6 +2155,52 @@ stop({bufnr}, {client_id}) *vim.lsp.semantic_tokens.stop()* • {client_id} (`integer`) The ID of the |vim.lsp.Client| +============================================================================== +Lua module: vim.lsp.document_color *lsp-document_color* + +enable({enable}, {bufnr}, {opts}) *vim.lsp.document_color.enable()* + Enables document highlighting from the given language client in the given + buffer. + + You can enable document highlighting from a supporting client as follows: >lua + vim.api.nvim_create_autocmd('LspAttach', { + callback = function(args) + local client = vim.lsp.get_client_by_id(args.data.client_id) + + if client:supports_method('textDocument/documentColor') + vim.lsp.document_color.enable(true, args.buf) + end + end + }) +< + + To "toggle", pass the inverse of `is_enabled()`: >lua + vim.lsp.document_color.enable(not vim.lsp.document_color.is_enabled()) +< + + Parameters: ~ + • {enable} (`boolean?`) True to enable, false to disable. (default: + `true`) + • {bufnr} (`integer?`) Buffer handle, or 0 for current. (default: 0) + • {opts} (`table?`) A table with the following fields: + • {style}? + (`'background'|'foreground'|'virtual'|string|fun(bufnr: integer, range: Range4, hex_code: string)`) + Highlight style. It can be one of the pre-defined styles, + a string to be used as virtual text, or a function that + receives the buffer handle, the range (start line, start + col, end line, end col) and the resolved hex color. + (default: `'background'`) + +is_enabled({bufnr}) *vim.lsp.document_color.is_enabled()* + Query whether document colors are enabled in the given buffer. + + Parameters: ~ + • {bufnr} (`integer?`) Buffer handle, or 0 for current. (default: 0) + + Return: ~ + (`boolean`) + + ============================================================================== Lua module: vim.lsp.util *lsp-util* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 4527ae0d15..2282b9c313 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -131,6 +131,8 @@ HIGHLIGHTS LSP • |vim.lsp.ClientConfig| gained `workspace_required`. +• Support for `textDocument/documentColor`: |lsp-document_color| + https://microsoft.github.io/language-server-protocol/specification/#textDocument_documentColor LUA diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 544466a9fc..a117217f17 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -12,6 +12,7 @@ local lsp = vim._defer_require('vim.lsp', { codelens = ..., --- @module 'vim.lsp.codelens' completion = ..., --- @module 'vim.lsp.completion' diagnostic = ..., --- @module 'vim.lsp.diagnostic' + document_color = ..., --- @module 'vim.lsp.document_color' handlers = ..., --- @module 'vim.lsp.handlers' inlay_hint = ..., --- @module 'vim.lsp.inlay_hint' log = ..., --- @module 'vim.lsp.log' diff --git a/runtime/lua/vim/lsp/document_color.lua b/runtime/lua/vim/lsp/document_color.lua new file mode 100644 index 0000000000..b03055c233 --- /dev/null +++ b/runtime/lua/vim/lsp/document_color.lua @@ -0,0 +1,350 @@ +local api = vim.api +local lsp = vim.lsp +local util = lsp.util +local ms = lsp.protocol.Methods + +local document_color_ns = api.nvim_create_namespace('nvim.lsp.document_color') +local document_color_augroup = api.nvim_create_augroup('nvim.lsp.document_color', {}) + +local M = {} + +--- @class (private) vim.lsp.document_color.HighlightInfo +--- @field hex_code string Resolved HEX color +--- @field range Range4 Range of the highlight +--- @field hl_group? string Highlight group name. Won't be present if the style is a custom function. + +--- @class (private) vim.lsp.document_color.BufState +--- @field enabled boolean Whether document_color is enabled for the current buffer +--- @field buf_version? integer Buffer version for which the color ranges correspond to +--- @field applied_version table (client_id -> buffer version) Last buffer version for which we applied color ranges +--- @field hl_info table (client_id -> color highlights) Processed highlight information + +--- @type table +local bufstates = {} + +--- @inlinedoc +--- @class vim.lsp.document_color.enable.Opts +--- +--- Highlight style. It can be one of the pre-defined styles, a string to be used as virtual text, or a +--- function that receives the buffer handle, the range (start line, start col, end line, end col) and +--- the resolved hex color. (default: `'background'`) +--- @field style? 'background'|'foreground'|'virtual'|string|fun(bufnr: integer, range: Range4, hex_code: string) + +-- Default options. +--- @type vim.lsp.document_color.enable.Opts +local document_color_opts = { style = 'background' } + +--- @param color string +local function get_contrast_color(color) + local r_s, g_s, b_s = color:match('^#(%x%x)(%x%x)(%x%x)$') + local r, g, b = tonumber(r_s, 16), tonumber(g_s, 16), tonumber(b_s, 16) + + -- Source: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance + -- Using power 2.2 is a close approximation to full piecewise transform + local R, G, B = (r / 255) ^ 2.2, (g / 255) ^ 2.2, (b / 255) ^ 2.2 + local is_bright = (0.2126 * R + 0.7152 * G + 0.0722 * B) > 0.5 + return is_bright and '#000000' or '#ffffff' +end + +--- Returns the hex string representing the given LSP color. +--- @param color lsp.Color +--- @return string +local function get_hex_code(color) + -- The RGB values in lsp.Color are in the [0-1] range, but we want them to be in the [0-255] range instead. + --- @param n number + color = vim.tbl_map(function(n) + return math.floor((n * 255) + 0.5) + end, color) + + return ('#%02x%02x%02x'):format(color.red, color.green, color.blue):lower() +end + +--- Cache of the highlight groups that we've already created. +--- @type table +local color_cache = {} + +--- Gets or creates the highlight group for the given LSP color information. +--- +--- @param hex_code string +--- @param style string +--- @return string +local function get_hl_group(hex_code, style) + if style ~= 'background' then + style = 'foreground' + end + + local hl_name = ('LspDocumentColor_%s_%s'):format(hex_code:sub(2), style) + + if not color_cache[hl_name] then + if style == 'background' then + api.nvim_set_hl(0, hl_name, { bg = hex_code, fg = get_contrast_color(hex_code) }) + else + api.nvim_set_hl(0, hl_name, { fg = hex_code }) + end + + color_cache[hl_name] = true + end + + return hl_name +end + +--- @param bufnr integer +--- @param enabled boolean +local function reset_bufstate(bufnr, enabled) + bufstates[bufnr] = { enabled = enabled, applied_version = {}, hl_info = {} } +end + +--- |lsp-handler| for the `textDocument/documentColor` method. +--- +--- @param err? lsp.ResponseError +--- @param result? lsp.ColorInformation[] +--- @param ctx lsp.HandlerContext +local function on_document_color(err, result, ctx) + if err then + lsp.log.error('document_color', err) + return + end + + local bufnr = assert(ctx.bufnr) + local bufstate = assert(bufstates[bufnr]) + local client_id = ctx.client_id + + if + util.buf_versions[bufnr] ~= ctx.version + or not result + or not api.nvim_buf_is_loaded(bufnr) + or not bufstate.enabled + then + return + end + + local hl_infos = {} --- @type vim.lsp.document_color.HighlightInfo[] + local style = document_color_opts.style + for _, res in ipairs(result) do + local range = { + res.range.start.line, + res.range.start.character, + res.range['end'].line, + res.range['end'].character, + } + local hex_code = get_hex_code(res.color) + local hl_info = { range = range, hex_code = hex_code } + + if type(style) == 'string' then + hl_info.hl_group = get_hl_group(hex_code, style) + end + + table.insert(hl_infos, hl_info) + end + bufstate.hl_info[client_id] = hl_infos + + bufstate.buf_version = ctx.version + api.nvim__redraw({ buf = bufnr, valid = true, flush = false }) +end + +--- @param bufnr integer +local function buf_clear(bufnr) + local bufstate = assert(bufstates[bufnr]) + local client_ids = vim.tbl_keys(bufstate.hl_info) --- @type integer[] + + for _, id in ipairs(client_ids) do + bufstate.hl_info[id] = {} + end + + api.nvim_buf_clear_namespace(bufnr, document_color_ns, 0, -1) + api.nvim__redraw({ buf = bufnr, valid = true, flush = false }) +end + +--- @param bufnr integer +--- @param client_id? integer +local function buf_refresh(bufnr, client_id) + util._refresh(ms.textDocument_documentColor, { + bufnr = bufnr, + handler = on_document_color, + client_id = client_id, + }) +end + +--- @param bufnr integer +local function buf_disable(bufnr) + buf_clear(bufnr) + reset_bufstate(bufnr, false) +end + +--- @param bufnr integer +local function buf_enable(bufnr) + reset_bufstate(bufnr, true) + + api.nvim_create_autocmd('LspNotify', { + buffer = bufnr, + group = document_color_augroup, + desc = 'Refresh document_color on document changes', + callback = function(args) + local method = args.data.method --- @type string + + if + (method == ms.textDocument_didChange or method == ms.textDocument_didOpen) + and bufstates[args.buf].enabled + then + buf_refresh(args.buf, args.data.client_id) + end + end, + }) + + api.nvim_create_autocmd('LspAttach', { + buffer = bufnr, + group = document_color_augroup, + desc = 'Enable document_color when LSP client attaches', + callback = function(args) + api.nvim_buf_attach(args.buf, false, { + on_reload = function(_, buf) + buf_clear(buf) + if bufstates[buf].enabled then + buf_refresh(buf) + end + end, + on_detach = function(_, buf) + buf_disable(buf) + end, + }) + end, + }) + + api.nvim_create_autocmd('LspDetach', { + buffer = bufnr, + group = document_color_augroup, + desc = 'Disable document_color if all supporting clients detach', + callback = function(args) + local clients = lsp.get_clients({ bufnr = args.buf, method = ms.textDocument_documentColor }) + + if + not vim.iter(clients):any(function(c) + return c.id ~= args.data.client_id + end) + then + -- There are no clients left in the buffer that support document color, so turn it off. + buf_disable(args.buf) + end + end, + }) + + buf_refresh(bufnr) +end + +--- Query whether document colors are enabled in the given buffer. +--- +--- @param bufnr? integer Buffer handle, or 0 for current. (default: 0) +--- @return boolean +function M.is_enabled(bufnr) + vim.validate('bufnr', bufnr, 'number', true) + + return bufstates[vim._resolve_bufnr(bufnr)].enabled +end + +--- Enables document highlighting from the given language client in the given buffer. +--- +--- You can enable document highlighting from a supporting client as follows: +--- ```lua +--- vim.api.nvim_create_autocmd('LspAttach', { +--- callback = function(args) +--- local client = vim.lsp.get_client_by_id(args.data.client_id) +--- +--- if client:supports_method('textDocument/documentColor') +--- vim.lsp.document_color.enable(true, args.buf) +--- end +--- end +--- }) +--- ``` +--- +--- To "toggle", pass the inverse of `is_enabled()`: +--- +--- ```lua +--- vim.lsp.document_color.enable(not vim.lsp.document_color.is_enabled()) +--- ``` +--- +--- @param enable? boolean True to enable, false to disable. (default: `true`) +--- @param bufnr? integer Buffer handle, or 0 for current. (default: 0) +--- @param opts? vim.lsp.document_color.enable.Opts +function M.enable(enable, bufnr, opts) + vim.validate('enable', enable, 'boolean', true) + vim.validate('bufnr', bufnr, 'number', true) + vim.validate('opts', opts, 'table', true) + + enable = enable == nil or enable + bufnr = vim._resolve_bufnr(bufnr) + document_color_opts = vim.tbl_extend('keep', opts or {}, document_color_opts) + + if enable then + buf_enable(bufnr) + else + buf_disable(bufnr) + end +end + +api.nvim_create_autocmd('ColorScheme', { + pattern = '*', + group = document_color_augroup, + desc = 'Refresh document_color', + callback = function() + color_cache = {} + + for _, bufnr in ipairs(api.nvim_list_bufs()) do + buf_clear(bufnr) + if api.nvim_buf_is_loaded(bufnr) and bufstates[bufnr].enabled then + buf_refresh(bufnr) + else + reset_bufstate(bufnr, false) + end + end + end, +}) + +api.nvim_set_decoration_provider(document_color_ns, { + on_win = function(_, _, bufnr) + if not bufstates[bufnr] then + reset_bufstate(bufnr, false) + end + local bufstate = assert(bufstates[bufnr]) + + local all_applied = #bufstate.applied_version > 0 + and vim.iter(pairs(bufstate.applied_version)):all(function(_, buf_version) + return buf_version == bufstate.buf_version + end) + + if bufstate.buf_version ~= util.buf_versions[bufnr] or all_applied then + return + end + + api.nvim_buf_clear_namespace(bufnr, document_color_ns, 0, -1) + + local style = document_color_opts.style + + for client_id, client_hls in pairs(bufstate.hl_info) do + if bufstate.applied_version[client_id] ~= bufstate.buf_version then + for _, hl in ipairs(client_hls) do + if type(style) == 'function' then + style(bufnr, hl.range, hl.hex_code) + elseif style == 'foreground' or style == 'background' then + api.nvim_buf_set_extmark(bufnr, document_color_ns, hl.range[1], hl.range[2], { + end_row = hl.range[3], + end_col = hl.range[4], + hl_group = hl.hl_group, + strict = false, + }) + else + -- Default swatch: \uf0c8 + local swatch = style == 'virtual' and ' ' or style + api.nvim_buf_set_extmark(bufnr, document_color_ns, hl.range[1], hl.range[2], { + virt_text = { { swatch, hl.hl_group } }, + virt_text_pos = 'inline', + }) + end + end + + bufstate.applied_version[client_id] = bufstate.buf_version + end + end + end, +}) + +return M diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 167dcb561b..64f09d2037 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -532,6 +532,9 @@ function protocol.make_client_capabilities() callHierarchy = { dynamicRegistration = false, }, + colorProvider = { + dynamicRegistration = false, + }, }, workspace = { symbol = { diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua index c9b260b6f6..804ce1e2b0 100755 --- a/src/gen/gen_vimdoc.lua +++ b/src/gen/gen_vimdoc.lua @@ -278,6 +278,7 @@ local config = { 'inlay_hint.lua', 'tagfunc.lua', 'semantic_tokens.lua', + 'document_color.lua', 'handlers.lua', 'util.lua', 'log.lua', diff --git a/test/functional/plugin/lsp/document_color_spec.lua b/test/functional/plugin/lsp/document_color_spec.lua new file mode 100644 index 0000000000..b08757de9a --- /dev/null +++ b/test/functional/plugin/lsp/document_color_spec.lua @@ -0,0 +1,211 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local t_lsp = require('test.functional.plugin.lsp.testutil') +local Screen = require('test.functional.ui.screen') + +local dedent = t.dedent +local eq = t.eq + +local api = n.api +local exec_lua = n.exec_lua +local insert = n.insert + +local clear_notrace = t_lsp.clear_notrace +local create_server_definition = t_lsp.create_server_definition + +describe('vim.lsp.document_color', function() + local text = dedent([[ +body { + color: #FFF; + background-color: rgb(0, 255, 255); +} +]]) + + local grid_without_colors = [[ + body { | + color: #FFF; | + background-color: rgb(0, 255, 255); | + } | + ^ | + {1:~ }|*8 + | + ]] + + local grid_with_colors = [[ + body { | + color: {2:#FFF}; | + background-color: {3:rgb(0, 255, 255)}; | + } | + ^ | + {1:~ }|*8 + | + ]] + + --- @type test.functional.ui.screen + local screen + + --- @type integer + local client_id + + --- @type integer + local bufnr + + before_each(function() + clear_notrace() + exec_lua(create_server_definition) + + screen = Screen.new() + screen:set_default_attr_ids { + [1] = { bold = true, foreground = Screen.colors.Blue1 }, + [2] = { background = Screen.colors.Gray100, foreground = Screen.colors.Gray0 }, + [3] = { background = Screen.colors.Cyan1, foreground = Screen.colors.Gray0 }, + [4] = { foreground = Screen.colors.Grey100 }, + [5] = { foreground = Screen.colors.Cyan1 }, + } + + bufnr = n.api.nvim_get_current_buf() + client_id = exec_lua(function() + _G.server = _G._create_server({ + capabilities = { + colorProvider = true, + }, + handlers = { + ['textDocument/documentColor'] = function(_, _, callback) + callback(nil, { + { + range = { + start = { line = 1, character = 9 }, + ['end'] = { line = 1, character = 13 }, + }, + color = { red = 1, green = 1, blue = 1 }, + }, + { + range = { + start = { line = 2, character = 20 }, + ['end'] = { line = 2, character = 36 }, + }, + color = { red = 0, green = 1, blue = 1 }, + }, + }) + end, + }, + }) + + return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd }) + end) + + insert(text) + + exec_lua(function() + vim.lsp.document_color.enable(true, bufnr) + end) + + screen:expect({ grid = grid_with_colors }) + end) + + after_each(function() + api.nvim_exec_autocmds('VimLeavePre', { modeline = false }) + end) + + it('clears document colors when sole client detaches', function() + exec_lua(function() + vim.lsp.stop_client(client_id) + end) + + screen:expect({ grid = grid_without_colors }) + end) + + it('does not clear document colors when one of several clients detaches', function() + local client_id2 = exec_lua(function() + _G.server2 = _G._create_server({ + capabilities = { + colorProvider = true, + }, + handlers = { + ['textDocument/documentColor'] = function(_, _, callback) + callback(nil, {}) + end, + }, + }) + local client_id2 = vim.lsp.start({ name = 'dummy2', cmd = _G.server2.cmd }) + vim.lsp.document_color.enable(true, bufnr) + return client_id2 + end) + + exec_lua(function() + vim.lsp.stop_client(client_id2) + end) + + screen:expect({ grid = grid_with_colors, unchanged = true }) + end) + + describe('is_enabled()', function() + it('returns true when document colors is enabled', function() + eq( + true, + exec_lua(function() + return vim.lsp.document_color.is_enabled(bufnr) + end) + ) + + exec_lua(function() + vim.lsp.stop_client(client_id) + end) + + eq( + false, + exec_lua(function() + return vim.lsp.document_color.is_enabled(bufnr) + end) + ) + end) + end) + + describe('enable()', function() + it('supports foreground styling', function() + local grid_with_fg_colors = [[ +body { | + color: {4:#FFF}; | + background-color: {5:rgb(0, 255, 255)}; | +} | +^ | +{1:~ }|*8 + | + ]] + + exec_lua(function() + vim.lsp.document_color.enable(true, bufnr, { style = 'foreground' }) + end) + + screen:expect({ grid = grid_with_fg_colors }) + end) + + it('supports custom swatch text', function() + local grid_with_swatches = [[ +body { | + color: {4: :) }#FFF; | + background-color: {5: :) }rgb(0, 255, 255); | +} | +^ | +{1:~ }|*8 + | + ]] + + exec_lua(function() + vim.lsp.document_color.enable(true, bufnr, { style = ' :) ' }) + end) + + screen:expect({ grid = grid_with_swatches }) + end) + + it('will not create highlights with custom style function', function() + exec_lua(function() + vim.lsp.document_color.enable(true, bufnr, { + style = function() end, + }) + end) + + screen:expect({ grid = grid_without_colors }) + end) + end) +end)