document_color.lua (14232B)
1 --- @brief This module provides LSP support for highlighting color references in a document. 2 --- Highlighting is enabled by default. 3 4 local api = vim.api 5 local lsp = vim.lsp 6 local util = lsp.util 7 local Range = vim.treesitter._range 8 9 local document_color_ns = api.nvim_create_namespace('nvim.lsp.document_color') 10 local document_color_augroup = api.nvim_create_augroup('nvim.lsp.document_color', {}) 11 12 local M = {} 13 14 --- @class (private) vim.lsp.document_color.HighlightInfo 15 --- @field lsp_info lsp.ColorInformation Unprocessed LSP color information 16 --- @field hex_code string Resolved HEX color 17 --- @field range Range4 Range of the highlight 18 --- @field hl_group? string Highlight group name. Won't be present if the style is a custom function. 19 20 --- @class (private) vim.lsp.document_color.BufState 21 --- @field enabled boolean Whether document_color is enabled for the current buffer 22 --- @field processed_version table<integer, integer?> (client_id -> buffer version) Buffer version for which the color ranges correspond to 23 --- @field applied_version table<integer, integer?> (client_id -> buffer version) Last buffer version for which we applied color ranges 24 --- @field hl_info table<integer, vim.lsp.document_color.HighlightInfo[]?> (client_id -> color highlights) Processed highlight information 25 26 --- @type table<integer, vim.lsp.document_color.BufState?> 27 local bufstates = {} 28 29 --- @type table<integer, integer> (client_id -> namespace ID) documentColor namespace ID for each client. 30 local client_ns = {} 31 32 --- @inlinedoc 33 --- @class vim.lsp.document_color.enable.Opts 34 --- 35 --- Highlight style. It can be one of the pre-defined styles, a string to be used as virtual text, or a 36 --- function that receives the buffer handle, the range (start line, start col, end line, end col) and 37 --- the resolved hex color. (default: `'background'`) 38 --- @field style? 'background'|'foreground'|'virtual'|string|fun(bufnr: integer, range: Range4, hex_code: string) 39 40 -- Default options. 41 --- @type vim.lsp.document_color.enable.Opts 42 local document_color_opts = { style = 'background' } 43 44 --- @param color string 45 local function get_contrast_color(color) 46 local r_s, g_s, b_s = color:match('^#(%x%x)(%x%x)(%x%x)$') 47 if not (r_s and g_s and b_s) then 48 error('Invalid color format: ' .. color) 49 end 50 local r, g, b = tonumber(r_s, 16), tonumber(g_s, 16), tonumber(b_s, 16) 51 if not (r and g and b) then 52 error('Invalid color format: ' .. color) 53 end 54 55 -- Source: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance 56 -- Using power 2.2 is a close approximation to full piecewise transform 57 local R, G, B = (r / 255) ^ 2.2, (g / 255) ^ 2.2, (b / 255) ^ 2.2 58 local is_bright = (0.2126 * R + 0.7152 * G + 0.0722 * B) > 0.5 59 return is_bright and '#000000' or '#ffffff' 60 end 61 62 --- Returns the hex string representing the given LSP color. 63 --- @param color lsp.Color 64 --- @return string 65 local function get_hex_code(color) 66 -- The RGB values in lsp.Color are in the [0-1] range, but we want them to be in the [0-255] range instead. 67 --- @param n number 68 color = vim.tbl_map(function(n) 69 return math.floor((n * 255) + 0.5) 70 end, color) 71 72 return ('#%02x%02x%02x'):format(color.red, color.green, color.blue):lower() 73 end 74 75 --- Cache of the highlight groups that we've already created. 76 --- @type table<string, true> 77 local color_cache = {} 78 79 --- Gets or creates the highlight group for the given LSP color information. 80 --- 81 --- @param hex_code string 82 --- @param style string 83 --- @return string 84 local function get_hl_group(hex_code, style) 85 if style ~= 'background' then 86 style = 'foreground' 87 end 88 89 local hl_name = ('LspDocumentColor_%s_%s'):format(hex_code:sub(2), style) 90 91 if not color_cache[hl_name] then 92 if style == 'background' then 93 api.nvim_set_hl(0, hl_name, { bg = hex_code, fg = get_contrast_color(hex_code) }) 94 else 95 api.nvim_set_hl(0, hl_name, { fg = hex_code }) 96 end 97 98 color_cache[hl_name] = true 99 end 100 101 return hl_name 102 end 103 104 --- @param bufnr integer 105 --- @param enabled boolean 106 local function reset_bufstate(bufnr, enabled) 107 bufstates[bufnr] = { 108 enabled = enabled, 109 processed_version = {}, 110 applied_version = {}, 111 hl_info = {}, 112 } 113 end 114 115 --- |lsp-handler| for the `textDocument/documentColor` method. 116 --- 117 --- @param err? lsp.ResponseError 118 --- @param result? lsp.ColorInformation[] 119 --- @param ctx lsp.HandlerContext 120 local function on_document_color(err, result, ctx) 121 if err then 122 lsp.log.error('document_color', err) 123 return 124 end 125 126 local bufnr = assert(ctx.bufnr) 127 local bufstate = assert(bufstates[bufnr]) 128 local client_id = ctx.client_id 129 130 if 131 util.buf_versions[bufnr] ~= ctx.version 132 or not result 133 or not api.nvim_buf_is_loaded(bufnr) 134 or not bufstate.enabled 135 then 136 return 137 end 138 139 if not client_ns[client_id] then 140 client_ns[client_id] = api.nvim_create_namespace('nvim.lsp.document_color.client_' .. client_id) 141 end 142 143 local hl_infos = {} --- @type vim.lsp.document_color.HighlightInfo[] 144 local style = document_color_opts.style 145 local position_encoding = assert(lsp.get_client_by_id(client_id)).offset_encoding 146 for _, res in ipairs(result) do 147 local range = { 148 res.range.start.line, 149 util._get_line_byte_from_position(bufnr, res.range.start, position_encoding), 150 res.range['end'].line, 151 util._get_line_byte_from_position(bufnr, res.range['end'], position_encoding), 152 } 153 local hex_code = get_hex_code(res.color) 154 --- @type vim.lsp.document_color.HighlightInfo 155 local hl_info = { range = range, hex_code = hex_code, lsp_info = res } 156 157 if type(style) == 'string' then 158 hl_info.hl_group = get_hl_group(hex_code, style) 159 end 160 161 table.insert(hl_infos, hl_info) 162 end 163 164 bufstate.hl_info[client_id] = hl_infos 165 bufstate.processed_version[client_id] = ctx.version 166 167 api.nvim__redraw({ buf = bufnr, valid = true, flush = false }) 168 end 169 170 --- @param bufnr integer 171 local function buf_clear(bufnr) 172 local bufstate = bufstates[bufnr] 173 if not bufstate then 174 return 175 end 176 177 local client_ids = vim.tbl_keys(bufstate.hl_info) --- @type integer[] 178 179 for _, client_id in ipairs(client_ids) do 180 bufstate.hl_info[client_id] = {} 181 api.nvim_buf_clear_namespace(bufnr, client_ns[client_id], 0, -1) 182 end 183 184 api.nvim__redraw({ buf = bufnr, valid = true, flush = false }) 185 end 186 187 --- @param bufnr integer 188 local function buf_disable(bufnr) 189 buf_clear(bufnr) 190 reset_bufstate(bufnr, false) 191 api.nvim_clear_autocmds({ 192 buffer = bufnr, 193 group = document_color_augroup, 194 }) 195 end 196 197 --- @param bufnr integer 198 local function buf_enable(bufnr) 199 reset_bufstate(bufnr, true) 200 api.nvim_clear_autocmds({ 201 buffer = bufnr, 202 group = document_color_augroup, 203 }) 204 205 api.nvim_buf_attach(bufnr, false, { 206 on_reload = function(_, buf) 207 buf_clear(buf) 208 if assert(bufstates[buf]).enabled then 209 M._buf_refresh(buf) 210 end 211 end, 212 on_detach = function(_, buf) 213 buf_disable(buf) 214 end, 215 }) 216 217 api.nvim_create_autocmd('LspNotify', { 218 buffer = bufnr, 219 group = document_color_augroup, 220 desc = 'Refresh document_color on document changes', 221 callback = function(args) 222 local method = args.data.method --- @type string 223 224 if 225 (method == 'textDocument/didChange' or method == 'textDocument/didOpen') 226 and assert(bufstates[args.buf]).enabled 227 then 228 M._buf_refresh(args.buf, args.data.client_id) 229 end 230 end, 231 }) 232 233 api.nvim_create_autocmd('LspDetach', { 234 buffer = bufnr, 235 group = document_color_augroup, 236 desc = 'Disable document_color if all supporting clients detach', 237 callback = function(args) 238 local clients = lsp.get_clients({ bufnr = args.buf, method = 'textDocument/documentColor' }) 239 240 if 241 not vim.iter(clients):any(function(c) 242 return c.id ~= args.data.client_id 243 end) 244 then 245 -- There are no clients left in the buffer that support document color, so turn it off. 246 buf_disable(args.buf) 247 end 248 end, 249 }) 250 251 M._buf_refresh(bufnr) 252 end 253 254 --- @param bufnr integer 255 --- @param client_id? integer 256 function M._buf_refresh(bufnr, client_id) 257 for _, client in 258 ipairs(lsp.get_clients({ 259 bufnr = bufnr, 260 id = client_id, 261 method = 'textDocument/documentColor', 262 })) 263 do 264 ---@type lsp.DocumentColorParams 265 local params = { textDocument = util.make_text_document_params(bufnr) } 266 client:request('textDocument/documentColor', params, on_document_color, bufnr) 267 end 268 end 269 270 --- Query whether document colors are enabled in the given buffer. 271 --- 272 --- @param bufnr? integer Buffer handle, or 0 for current. (default: 0) 273 --- @return boolean 274 function M.is_enabled(bufnr) 275 vim.validate('bufnr', bufnr, 'number', true) 276 277 bufnr = vim._resolve_bufnr(bufnr) 278 279 if not bufstates[bufnr] then 280 reset_bufstate(bufnr, false) 281 end 282 283 return assert(bufstates[bufnr]).enabled 284 end 285 286 --- Enables document highlighting from the given language client in the given buffer. 287 --- 288 --- To "toggle", pass the inverse of `is_enabled()`: 289 --- 290 --- ```lua 291 --- vim.lsp.document_color.enable(not vim.lsp.document_color.is_enabled()) 292 --- ``` 293 --- 294 --- @param enable? boolean True to enable, false to disable. (default: `true`) 295 --- @param bufnr? integer Buffer handle, or 0 for current. (default: 0) 296 --- @param opts? vim.lsp.document_color.enable.Opts 297 function M.enable(enable, bufnr, opts) 298 vim.validate('enable', enable, 'boolean', true) 299 vim.validate('bufnr', bufnr, 'number', true) 300 vim.validate('opts', opts, 'table', true) 301 302 enable = enable == nil or enable 303 bufnr = vim._resolve_bufnr(bufnr) 304 document_color_opts = vim.tbl_extend('keep', opts or {}, document_color_opts) 305 306 if enable then 307 buf_enable(bufnr) 308 else 309 buf_disable(bufnr) 310 end 311 end 312 313 api.nvim_create_autocmd('ColorScheme', { 314 pattern = '*', 315 group = document_color_augroup, 316 desc = 'Refresh document_color', 317 callback = function() 318 color_cache = {} 319 320 for _, bufnr in ipairs(api.nvim_list_bufs()) do 321 buf_clear(bufnr) 322 if api.nvim_buf_is_loaded(bufnr) and vim.tbl_get(bufstates, bufnr, 'enabled') then 323 M._buf_refresh(bufnr) 324 else 325 reset_bufstate(bufnr, false) 326 end 327 end 328 end, 329 }) 330 331 api.nvim_set_decoration_provider(document_color_ns, { 332 on_win = function(_, _, bufnr) 333 if not bufstates[bufnr] then 334 reset_bufstate(bufnr, false) 335 end 336 local bufstate = assert(bufstates[bufnr]) 337 338 local style = document_color_opts.style 339 340 for client_id, client_hls in pairs(bufstate.hl_info) do 341 if 342 bufstate.processed_version[client_id] == util.buf_versions[bufnr] 343 and bufstate.processed_version[client_id] ~= bufstate.applied_version[client_id] 344 then 345 api.nvim_buf_clear_namespace(bufnr, client_ns[client_id], 0, -1) 346 347 for _, hl in ipairs(client_hls) do 348 if type(style) == 'function' then 349 style(bufnr, hl.range, hl.hex_code) 350 elseif style == 'foreground' or style == 'background' then 351 api.nvim_buf_set_extmark(bufnr, client_ns[client_id], hl.range[1], hl.range[2], { 352 end_row = hl.range[3], 353 end_col = hl.range[4], 354 hl_group = hl.hl_group, 355 strict = false, 356 }) 357 else 358 -- Default swatch: \uf0c8 359 local swatch = style == 'virtual' and ' ' or style 360 api.nvim_buf_set_extmark(bufnr, client_ns[client_id], hl.range[1], hl.range[2], { 361 virt_text = { { swatch, hl.hl_group } }, 362 virt_text_pos = 'inline', 363 }) 364 end 365 end 366 367 bufstate.applied_version[client_id] = bufstate.processed_version[client_id] 368 end 369 end 370 end, 371 }) 372 373 --- @param bufstate vim.lsp.document_color.BufState 374 --- @return vim.lsp.document_color.HighlightInfo?, integer? 375 local function get_hl_info_under_cursor(bufstate) 376 local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(0)) --- @type integer, integer 377 cursor_row = cursor_row - 1 -- Convert to 0-based index 378 local cursor_range = { cursor_row, cursor_col, cursor_row, cursor_col } --- @type Range4 379 380 for client_id, hls in pairs(bufstate.hl_info) do 381 for _, hl in ipairs(hls) do 382 if Range.contains(hl.range, cursor_range) then 383 return hl, client_id 384 end 385 end 386 end 387 end 388 389 --- Select from a list of presentations for the color under the cursor. 390 function M.color_presentation() 391 local bufnr = api.nvim_get_current_buf() 392 local bufstate = bufstates[bufnr] 393 if not bufstate then 394 vim.notify('documentColor is not enabled for this buffer.', vim.log.levels.WARN) 395 return 396 end 397 398 local hl_info, client_id = get_hl_info_under_cursor(bufstate) 399 if not hl_info or not client_id then 400 vim.notify('No color information under cursor.', vim.log.levels.WARN) 401 return 402 end 403 404 local uri = vim.uri_from_bufnr(bufnr) 405 local client = assert(lsp.get_client_by_id(client_id)) 406 407 --- @type lsp.ColorPresentationParams 408 local params = { 409 textDocument = { uri = uri }, 410 color = hl_info.lsp_info.color, 411 range = { 412 start = { line = hl_info.range[1], character = hl_info.range[2] }, 413 ['end'] = { line = hl_info.range[3], character = hl_info.range[4] }, 414 }, 415 } 416 417 --- @param result lsp.ColorPresentation[] 418 client:request('textDocument/colorPresentation', params, function(err, result, ctx) 419 if err then 420 lsp.log.error('color_presentation', err) 421 return 422 end 423 424 if 425 util.buf_versions[bufnr] ~= ctx.version 426 or not next(result) 427 or not api.nvim_buf_is_loaded(bufnr) 428 or not bufstate.enabled 429 then 430 return 431 end 432 433 vim.ui.select(result, { 434 kind = 'color_presentation', 435 format_item = function(item) 436 return item.label 437 end, 438 }, function(choice) 439 if not choice then 440 return 441 end 442 443 local text_edits = {} --- @type lsp.TextEdit[] 444 if choice.textEdit then 445 text_edits[#text_edits + 1] = choice.textEdit 446 else 447 -- If there's no textEdit, we should insert the label. 448 text_edits[#text_edits + 1] = { range = params.range, newText = choice.label } 449 end 450 vim.list_extend(text_edits, choice.additionalTextEdits or {}) 451 452 util.apply_text_edits(text_edits, bufnr, client.offset_encoding) 453 end) 454 end, bufnr) 455 end 456 457 return M