neovim

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

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