neovim

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

semantic_tokens.lua (35407B)


      1 local api = vim.api
      2 local bit = require('bit')
      3 local util = require('vim.lsp.util')
      4 local Range = require('vim.treesitter._range')
      5 local uv = vim.uv
      6 
      7 local Capability = require('vim.lsp._capability')
      8 
      9 local M = {}
     10 
     11 --- @class (private) STTokenRange
     12 --- @field line integer line number 0-based
     13 --- @field start_col integer start column 0-based
     14 --- @field end_line integer end line number 0-based
     15 --- @field end_col integer end column 0-based
     16 --- @field type string token type as string
     17 --- @field modifiers table<string,boolean> token modifiers as a set. E.g., { static = true, readonly = true }
     18 --- @field marked boolean whether this token has had extmarks applied
     19 
     20 --- @class (private) STCurrentResult
     21 --- @field version? integer document version associated with this result
     22 --- @field result_id? string resultId from the server; used with delta requests
     23 --- @field highlights? STTokenRange[] cache of highlight ranges for this document version
     24 --- @field tokens? integer[] raw token array as received by the server. used for calculating delta responses
     25 --- @field namespace_cleared? boolean whether the namespace was cleared for this result yet
     26 
     27 --- @class (private) STActiveRequest
     28 --- @field request_id? integer the LSP request ID of the most recent request sent to the server
     29 --- @field version? integer the document version associated with the most recent request
     30 
     31 --- @class (private) STClientState
     32 --- @field namespace integer
     33 --- @field supports_range boolean
     34 --- @field supports_delta boolean
     35 --- @field active_request STActiveRequest
     36 --- @field active_range_request STActiveRequest
     37 --- @field current_result STCurrentResult
     38 --- @field has_full_result boolean
     39 
     40 ---@class (private) STHighlighter : vim.lsp.Capability
     41 ---@field active table<integer, STHighlighter>
     42 ---@field bufnr integer
     43 ---@field augroup integer augroup for buffer events
     44 ---@field debounce integer milliseconds to debounce requests for new tokens
     45 ---@field timer table uv_timer for debouncing requests for new tokens
     46 ---@field client_state table<integer, STClientState>
     47 local STHighlighter = {
     48  name = 'semantic_tokens',
     49  method = 'textDocument/semanticTokens',
     50  active = {},
     51 }
     52 STHighlighter.__index = STHighlighter
     53 setmetatable(STHighlighter, Capability)
     54 Capability.all[STHighlighter.name] = STHighlighter
     55 
     56 --- Extracts modifier strings from the encoded number in the token array
     57 ---
     58 ---@param x integer
     59 ---@param modifiers_table table<integer,string>
     60 ---@return table<string, boolean>
     61 local function modifiers_from_number(x, modifiers_table)
     62  local modifiers = {} ---@type table<string,boolean>
     63  local idx = 1
     64  while x > 0 do
     65    if bit.band(x, 1) == 1 then
     66      modifiers[modifiers_table[idx]] = true
     67    end
     68    x = bit.rshift(x, 1)
     69    idx = idx + 1
     70  end
     71 
     72  return modifiers
     73 end
     74 
     75 --- Converts a raw token list to a list of highlight ranges used by the on_win callback
     76 ---
     77 ---@async
     78 ---@param data integer[]
     79 ---@param bufnr integer
     80 ---@param client vim.lsp.Client
     81 ---@param request STActiveRequest
     82 ---@param ranges STTokenRange[]
     83 ---@return STTokenRange[]
     84 local function tokens_to_ranges(data, bufnr, client, request, ranges)
     85  local legend = client.server_capabilities.semanticTokensProvider.legend
     86  local token_types = legend.tokenTypes
     87  local token_modifiers = legend.tokenModifiers
     88  local encoding = client.offset_encoding
     89  local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false)
     90  -- For all encodings, \r\n takes up two code points, and \n (or \r) takes up one.
     91  local eol_offset = vim.bo.fileformat[bufnr] == 'dos' and 2 or 1
     92  local version = request.version
     93  local request_id = request.request_id
     94  local last_insert_idx = 1
     95 
     96  local start = uv.hrtime()
     97  local ms_to_ns = 1e6
     98  local yield_interval_ns = 5 * ms_to_ns
     99  local co, is_main = coroutine.running()
    100 
    101  local line ---@type integer?
    102  local start_char = 0
    103  for i = 1, #data, 5 do
    104    -- if this function is called from the main coroutine, let it run to completion with no yield
    105    if not is_main then
    106      local elapsed_ns = uv.hrtime() - start
    107 
    108      if elapsed_ns > yield_interval_ns then
    109        vim.schedule(function()
    110          -- Ensure the request hasn't become stale since the last time the coroutine ran.
    111          -- If it's stale, we don't resume the coroutine so it'll be garbage collected.
    112          if
    113            version == util.buf_versions[bufnr]
    114            and request_id == request.request_id
    115            and api.nvim_buf_is_valid(bufnr)
    116          then
    117            coroutine.resume(co)
    118          end
    119        end)
    120 
    121        coroutine.yield()
    122        start = uv.hrtime()
    123      end
    124    end
    125 
    126    local delta_line = data[i]
    127    line = line and line + delta_line or delta_line
    128    local delta_start = data[i + 1]
    129    start_char = delta_line == 0 and start_char + delta_start or delta_start
    130 
    131    -- data[i+3] +1 because Lua tables are 1-indexed
    132    local token_type = token_types[data[i + 3] + 1]
    133 
    134    if token_type then
    135      local modifiers = modifiers_from_number(data[i + 4], token_modifiers)
    136      local end_char = start_char + data[i + 2] --- @type integer LuaLS bug
    137      local buf_line = lines[line + 1] or ''
    138      local end_line = line ---@type integer
    139      local start_col = vim.str_byteindex(buf_line, encoding, start_char, false)
    140 
    141      ---@type integer LuaLS bug, type must be marked explicitly here
    142      local new_end_char = end_char - vim.str_utfindex(buf_line, encoding) - eol_offset
    143      -- While end_char goes past the given line, extend the token range to the next line
    144      while new_end_char > 0 do
    145        end_char = new_end_char
    146        end_line = end_line + 1
    147        buf_line = lines[end_line + 1] or ''
    148        new_end_char = new_end_char - vim.str_utfindex(buf_line, encoding) - eol_offset
    149      end
    150 
    151      local end_col = vim.str_byteindex(buf_line, encoding, end_char, false)
    152 
    153      ---@type STTokenRange
    154      local range = {
    155        line = line,
    156        end_line = end_line,
    157        start_col = start_col,
    158        end_col = end_col,
    159        type = token_type,
    160        modifiers = modifiers,
    161        marked = false,
    162      }
    163 
    164      if last_insert_idx < #ranges then
    165        local needs_insert = true
    166        local idx = vim.list.bisect(ranges, { line = range.line }, {
    167          lo = last_insert_idx,
    168          key = function(highlight)
    169            return highlight.line
    170          end,
    171        })
    172        while idx <= #ranges do
    173          local token = ranges[idx]
    174 
    175          if
    176            token.line > range.line
    177            or (token.line == range.line and token.start_col > range.start_col)
    178          then
    179            break
    180          end
    181 
    182          if
    183            range.line == token.line
    184            and range.start_col == token.start_col
    185            and range.end_line == token.end_line
    186            and range.end_col == token.end_col
    187            and range.type == token.type
    188          then
    189            needs_insert = false
    190            break
    191          end
    192 
    193          idx = idx + 1
    194        end
    195 
    196        last_insert_idx = idx
    197        if needs_insert then
    198          table.insert(ranges, last_insert_idx, range)
    199        end
    200      else
    201        last_insert_idx = #ranges + 1
    202        ranges[last_insert_idx] = range
    203      end
    204    end
    205  end
    206 
    207  return ranges
    208 end
    209 
    210 --- Construct a new STHighlighter for the buffer
    211 ---
    212 ---@private
    213 ---@param bufnr integer
    214 ---@return STHighlighter
    215 function STHighlighter:new(bufnr)
    216  self.debounce = 200
    217  self = Capability.new(self, bufnr)
    218 
    219  api.nvim_buf_attach(bufnr, false, {
    220    on_lines = function(_, buf)
    221      local highlighter = STHighlighter.active[buf]
    222      if not highlighter then
    223        return true
    224      end
    225      highlighter:on_change()
    226    end,
    227    on_reload = function(_, buf)
    228      local highlighter = STHighlighter.active[buf]
    229      if highlighter then
    230        highlighter:reset()
    231        highlighter:send_request()
    232      end
    233    end,
    234  })
    235 
    236  return self
    237 end
    238 
    239 ---@package
    240 function STHighlighter:on_attach(client_id)
    241  local client = vim.lsp.get_client_by_id(client_id)
    242  local state = self.client_state[client_id]
    243  if not state then
    244    state = {
    245      namespace = api.nvim_create_namespace('nvim.lsp.semantic_tokens:' .. client_id),
    246      supports_range = client
    247          and client:supports_method('textDocument/semanticTokens/range', self.bufnr)
    248        or false,
    249      supports_delta = client
    250          and client:supports_method('textDocument/semanticTokens/full/delta', self.bufnr)
    251        or false,
    252      active_request = {},
    253      active_range_request = {},
    254      current_result = {},
    255      has_full_result = false,
    256    }
    257    self.client_state[client_id] = state
    258  end
    259 
    260  api.nvim_create_autocmd({ 'BufWinEnter', 'InsertLeave' }, {
    261    buffer = self.bufnr,
    262    group = self.augroup,
    263    callback = function()
    264      self:send_request()
    265    end,
    266  })
    267 
    268  if state.supports_range then
    269    api.nvim_create_autocmd('WinScrolled', {
    270      buffer = self.bufnr,
    271      group = self.augroup,
    272      callback = function()
    273        self:on_change()
    274      end,
    275    })
    276  end
    277 
    278  self:send_request()
    279 end
    280 
    281 ---@package
    282 function STHighlighter:on_detach(client_id)
    283  local state = self.client_state[client_id]
    284  if state then
    285    --TODO: delete namespace if/when that becomes possible
    286    api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
    287    api.nvim_clear_autocmds({ group = self.augroup })
    288    self.client_state[client_id] = nil
    289  end
    290 end
    291 
    292 --- This is the entry point for getting all the tokens in a buffer.
    293 ---
    294 --- For the given clients (or all attached, if not provided), this sends semantic token requests to
    295 --- ask for semantic tokens. If the server supports range requests and a full result has not been
    296 --- processed yet, it will send a range request for the current visible range. Additionally, if a
    297 --- result for the current document version hasn't been processed yet, it sends either a full or
    298 --- delta request, depending on what the server supports and whether there's a current full result
    299 --- for the previous document version.
    300 ---
    301 --- This function will skip full/delta requests on servers where there is an already an active
    302 --- full/delta request in flight for the same version. If there is a stale request in flight, that
    303 --- is cancelled prior to sending a new one.
    304 ---
    305 --- Finally, for successful requests, the requestId (full/delta) and document version are saved to
    306 --- facilitate document synchronization in the response.
    307 ---
    308 ---@package
    309 function STHighlighter:send_request()
    310  local version = util.buf_versions[self.bufnr]
    311 
    312  self:reset_timer()
    313 
    314  for client_id, state in pairs(self.client_state) do
    315    local client = vim.lsp.get_client_by_id(client_id)
    316    if client then
    317      -- If the server supports range and there's no full result yet, then start with a range
    318      -- request
    319      if state.supports_range and not state.has_full_result then
    320        self:send_range_request(client, state, version)
    321      end
    322 
    323      if
    324        (not state.has_full_result or state.current_result.version ~= version)
    325        and state.active_request.version ~= version
    326      then
    327        self:send_full_delta_request(client, state, version)
    328      end
    329    end
    330  end
    331 end
    332 
    333 --- Send a range request for the visible area
    334 ---
    335 ---@private
    336 ---@param client vim.lsp.Client
    337 ---@param state STClientState
    338 ---@param version integer
    339 function STHighlighter:send_range_request(client, state, version)
    340  local active_request = state.active_range_request
    341 
    342  -- cancel stale in-flight request
    343  if active_request and active_request.request_id then
    344    client:cancel_request(active_request.request_id)
    345    active_request.request_id = nil
    346    active_request.version = nil
    347  end
    348 
    349  ---@type lsp.SemanticTokensRangeParams
    350  local params = {
    351    textDocument = util.make_text_document_params(self.bufnr),
    352    range = self:get_visible_range(),
    353  }
    354 
    355  ---@type vim.lsp.protocol.Method.ClientToServer.Request
    356  local method = 'textDocument/semanticTokens/range'
    357 
    358  ---@param response? lsp.SemanticTokens
    359  local success, request_id = client:request(method, params, function(err, response, ctx)
    360    local bufnr = assert(ctx.bufnr)
    361    local highlighter = STHighlighter.active[bufnr]
    362    if not highlighter then
    363      return
    364    end
    365 
    366    -- Only process range response if we got a valid response and don't have a full result yet
    367    if err or not response or state.has_full_result then
    368      active_request.request_id = nil
    369      active_request.version = nil
    370      return
    371    end
    372 
    373    coroutine.wrap(STHighlighter.process_response)(
    374      highlighter,
    375      response,
    376      client,
    377      ctx.request_id,
    378      version,
    379      true
    380    )
    381  end, self.bufnr)
    382 
    383  if success then
    384    active_request.request_id = request_id
    385    active_request.version = version
    386  end
    387 end
    388 
    389 --- Send a full or delta request
    390 ---
    391 ---@private
    392 ---@param client vim.lsp.Client
    393 ---@param state STClientState
    394 ---@param version integer
    395 function STHighlighter:send_full_delta_request(client, state, version)
    396  local current_result = state.current_result
    397  local active_request = state.active_request
    398 
    399  -- cancel stale in-flight request
    400  if active_request.request_id then
    401    client:cancel_request(active_request.request_id)
    402    active_request.request_id = nil
    403    active_request.version = nil
    404  end
    405 
    406  ---@type lsp.SemanticTokensParams|lsp.SemanticTokensDeltaParams
    407  local params = { textDocument = util.make_text_document_params(self.bufnr) }
    408 
    409  ---@type vim.lsp.protocol.Method.ClientToServer.Request
    410  local method = 'textDocument/semanticTokens/full'
    411 
    412  if state.supports_delta and current_result.result_id then
    413    method = 'textDocument/semanticTokens/full/delta'
    414    params.previousResultId = current_result.result_id
    415  end
    416 
    417  ---@param response? lsp.SemanticTokens|lsp.SemanticTokensDelta
    418  local success, request_id = client:request(method, params, function(err, response, ctx)
    419    local bufnr = assert(ctx.bufnr)
    420    local highlighter = STHighlighter.active[bufnr]
    421    if not highlighter then
    422      return
    423    end
    424 
    425    if err or not response then
    426      active_request.request_id = nil
    427      active_request.version = nil
    428      return
    429    end
    430 
    431    coroutine.wrap(STHighlighter.process_response)(
    432      highlighter,
    433      response,
    434      client,
    435      ctx.request_id,
    436      version,
    437      false
    438    )
    439  end, self.bufnr)
    440 
    441  if success then
    442    active_request.request_id = request_id
    443    active_request.version = version
    444  end
    445 end
    446 
    447 ---@private
    448 function STHighlighter:cancel_active_request(client_id)
    449  local state = self.client_state[client_id]
    450  local client = vim.lsp.get_client_by_id(client_id)
    451 
    452  ---@param request STActiveRequest
    453  local function clear(request)
    454    if client and request.request_id then
    455      client:cancel_request(request.request_id)
    456      request.request_id = nil
    457      request.version = nil
    458    end
    459  end
    460 
    461  clear(state.active_range_request)
    462  clear(state.active_request)
    463 end
    464 
    465 --- Gets a range that encompasses all visible lines across all windows
    466 --- @private
    467 --- @return lsp.Range
    468 function STHighlighter:get_visible_range()
    469  local wins = vim.fn.win_findbuf(self.bufnr)
    470  local min_start, max_end = nil, nil
    471 
    472  for _, win in ipairs(wins) do
    473    local wininfo = vim.fn.getwininfo(win)[1]
    474    if wininfo then
    475      local start_line = wininfo.topline - 1
    476      local end_line = wininfo.botline
    477      if not min_start or start_line < min_start then
    478        min_start = start_line
    479      end
    480      if not max_end or end_line > max_end then
    481        max_end = end_line
    482      end
    483    end
    484  end
    485 
    486  ---@type lsp.Range
    487  return {
    488    ['start'] = { line = min_start or 0, character = 0 },
    489    ['end'] = { line = max_end or 0, character = 0 },
    490  }
    491 end
    492 
    493 --- This function will parse the semantic token responses and set up the cache
    494 --- (current_result). It also performs document synchronization by checking the
    495 --- version of the document associated with the resulting request_id and only
    496 --- performing work if the response is not out-of-date.
    497 ---
    498 --- Delta edits are applied if necessary, and new highlight ranges are calculated
    499 --- and stored in the buffer state.
    500 ---
    501 --- Finally, a redraw command is issued to force nvim to redraw the screen to
    502 --- pick up changed highlight tokens.
    503 ---
    504 ---@async
    505 ---@param response lsp.SemanticTokens|lsp.SemanticTokensDelta
    506 ---@param client vim.lsp.Client
    507 ---@param request_id integer
    508 ---@param version integer
    509 ---@param is_range_request boolean
    510 ---@private
    511 function STHighlighter:process_response(response, client, request_id, version, is_range_request)
    512  local state = self.client_state[client.id]
    513  if not state then
    514    return
    515  end
    516 
    517  ---@type STActiveRequest
    518  local active_request
    519  if is_range_request then
    520    active_request = state.active_range_request
    521  else
    522    active_request = state.active_request
    523  end
    524 
    525  -- ignore stale responses
    526  if active_request.request_id and request_id ~= active_request.request_id then
    527    return
    528  end
    529 
    530  if not api.nvim_buf_is_valid(self.bufnr) then
    531    return
    532  end
    533 
    534  -- if we have a response to a delta request, update the state of our tokens
    535  -- appropriately. if it's a full response, just use that
    536  local tokens ---@type integer[]
    537  local token_edits = response.edits
    538  if token_edits then
    539    table.sort(token_edits, function(a, b)
    540      return a.start < b.start
    541    end)
    542 
    543    tokens = {} --- @type integer[]
    544    local old_tokens = assert(state.current_result.tokens)
    545    local idx = 1
    546    for _, token_edit in ipairs(token_edits) do
    547      vim.list_extend(tokens, old_tokens, idx, token_edit.start)
    548      if token_edit.data then
    549        vim.list_extend(tokens, token_edit.data)
    550      end
    551      idx = token_edit.start + token_edit.deleteCount + 1
    552    end
    553    vim.list_extend(tokens, old_tokens, idx)
    554  else
    555    tokens = response.data
    556  end
    557 
    558  local current_result = state.current_result
    559  local version_changed = version ~= current_result.version
    560  local highlights = {} --- @type STTokenRange[]
    561  if current_result.highlights and not version_changed then
    562    highlights = assert(current_result.highlights)
    563  end
    564 
    565  -- convert token list to highlight ranges
    566  -- this could yield and run over multiple event loop iterations
    567  highlights = tokens_to_ranges(tokens, self.bufnr, client, active_request, highlights)
    568 
    569  -- if this was a full result, mark the state as having processed it
    570  if not is_range_request then
    571    state.has_full_result = true
    572  end
    573 
    574  -- reset active request
    575  active_request.request_id = nil
    576  active_request.version = nil
    577 
    578  -- update the state with the new results
    579  current_result.version = version
    580  current_result.result_id = not is_range_request and response.resultId or nil
    581  current_result.tokens = tokens
    582  current_result.highlights = highlights
    583  if version_changed then
    584    current_result.namespace_cleared = false
    585  end
    586 
    587  -- redraw all windows displaying buffer
    588  api.nvim__redraw({ buf = self.bufnr, valid = true })
    589 end
    590 
    591 --- @param bufnr integer
    592 --- @param ns integer
    593 --- @param token STTokenRange
    594 --- @param hl_group string
    595 --- @param priority integer
    596 local function set_mark(bufnr, ns, token, hl_group, priority)
    597  api.nvim_buf_set_extmark(bufnr, ns, token.line, token.start_col, {
    598    hl_group = hl_group,
    599    end_line = token.end_line,
    600    end_col = token.end_col,
    601    priority = priority,
    602    strict = false,
    603  })
    604 end
    605 
    606 --- @param lnum integer
    607 --- @param foldend integer?
    608 --- @return boolean, integer?
    609 local function check_fold(lnum, foldend)
    610  if foldend and lnum <= foldend then
    611    return true, foldend
    612  end
    613 
    614  local folded = vim.fn.foldclosed(lnum)
    615 
    616  if folded == -1 then
    617    return false, nil
    618  end
    619 
    620  return folded ~= lnum, vim.fn.foldclosedend(lnum)
    621 end
    622 
    623 --- on_win handler for the decoration provider (see |nvim_set_decoration_provider|)
    624 ---
    625 --- If there is a current result for the buffer and the version matches the
    626 --- current document version, then the tokens are valid and can be applied. As
    627 --- the buffer is drawn, this function will add extmark highlights for every
    628 --- token in the range of visible lines. Once a highlight has been added, it
    629 --- sticks around until the document changes and there's a new set of matching
    630 --- highlight tokens available.
    631 ---
    632 --- If this is the first time a buffer is being drawn with a new set of
    633 --- highlights for the current document version, the namespace is cleared to
    634 --- remove extmarks from the last version. It's done here instead of the response
    635 --- handler to avoid the "blink" that occurs due to the timing between the
    636 --- response handler and the actual redraw.
    637 ---
    638 ---@package
    639 ---@param topline integer
    640 ---@param botline integer
    641 function STHighlighter:on_win(topline, botline)
    642  for client_id, state in pairs(self.client_state) do
    643    local current_result = state.current_result
    644    if current_result.version == util.buf_versions[self.bufnr] then
    645      if not current_result.namespace_cleared then
    646        api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
    647        current_result.namespace_cleared = true
    648      end
    649 
    650      -- We can't use ephemeral extmarks because the buffer updates are not in
    651      -- sync with the list of semantic tokens. There's a delay between the
    652      -- buffer changing and when the LSP server can respond with updated
    653      -- tokens, and we don't want to "blink" the token highlights while
    654      -- updates are in flight, and we don't want to use stale tokens because
    655      -- they likely won't line up right with the actual buffer.
    656      --
    657      -- Instead, we have to use normal extmarks that can attach to locations
    658      -- in the buffer and are persisted between redraws.
    659      --
    660      -- `strict = false` is necessary here for the 1% of cases where the
    661      -- current result doesn't actually match the buffer contents. Some
    662      -- LSP servers can respond with stale tokens on requests if they are
    663      -- still processing changes from a didChange notification.
    664      --
    665      -- LSP servers that do this _should_ follow up known stale responses
    666      -- with a refresh notification once they've finished processing the
    667      -- didChange notification, which would re-synchronize the tokens from
    668      -- our end.
    669      --
    670      -- The server I know of that does this is clangd when the preamble of
    671      -- a file changes and the token request is processed with a stale
    672      -- preamble while the new one is still being built. Once the preamble
    673      -- finishes, clangd sends a refresh request which lets the client
    674      -- re-synchronize the tokens.
    675 
    676      local function set_mark0(token, hl_group, delta)
    677        set_mark(
    678          self.bufnr,
    679          state.namespace,
    680          token,
    681          hl_group,
    682          vim.hl.priorities.semantic_tokens + delta
    683        )
    684      end
    685 
    686      local ft = vim.bo[self.bufnr].filetype
    687      local highlights = assert(current_result.highlights)
    688      local first = vim.list.bisect(highlights, { end_line = topline }, {
    689        key = function(highlight)
    690          return highlight.end_line
    691        end,
    692      })
    693      local last = vim.list.bisect(highlights, { line = botline }, {
    694        lo = first,
    695        bound = 'upper',
    696        key = function(highlight)
    697          return highlight.line
    698        end,
    699      }) - 1
    700 
    701      --- @type boolean?, integer?
    702      local is_folded, foldend
    703 
    704      for i = first, last do
    705        local token = assert(highlights[i])
    706 
    707        is_folded, foldend = check_fold(token.line + 1, foldend)
    708 
    709        if not is_folded and not token.marked then
    710          set_mark0(token, string.format('@lsp.type.%s.%s', token.type, ft), 0)
    711          for modifier in pairs(token.modifiers) do
    712            set_mark0(token, string.format('@lsp.mod.%s.%s', modifier, ft), 1)
    713            set_mark0(token, string.format('@lsp.typemod.%s.%s.%s', token.type, modifier, ft), 2)
    714          end
    715          token.marked = true
    716 
    717          api.nvim_exec_autocmds('LspTokenUpdate', {
    718            buffer = self.bufnr,
    719            modeline = false,
    720            data = {
    721              token = token,
    722              client_id = client_id,
    723            },
    724          })
    725        end
    726      end
    727    end
    728  end
    729 end
    730 
    731 --- Reset the buffer's highlighting state and clears the extmark highlights.
    732 ---
    733 ---@package
    734 function STHighlighter:reset()
    735  for client_id, state in pairs(self.client_state) do
    736    api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
    737    state.current_result = {}
    738    state.has_full_result = false
    739    self:cancel_active_request(client_id)
    740  end
    741 end
    742 
    743 --- Mark a client's results as dirty. This method will cancel any active
    744 --- requests to the server and pause new highlights from being added
    745 --- in the on_win callback. The rest of the current results are saved
    746 --- in case the server supports delta requests.
    747 ---
    748 ---@package
    749 ---@param client_id integer
    750 function STHighlighter:mark_dirty(client_id)
    751  local state = assert(self.client_state[client_id])
    752 
    753  -- if we clear the version from current_result, it'll cause the next
    754  -- full/delta request to be sent and will also pause new highlights
    755  -- from being added in on_win until a new result comes from the server
    756  if state.current_result then
    757    state.current_result.version = nil
    758  end
    759 
    760  -- clearing this flag will also allow range requests to fire to
    761  -- potentially get a faster result
    762  state.has_full_result = false
    763 
    764  self:cancel_active_request(client_id)
    765 end
    766 
    767 ---@package
    768 function STHighlighter:on_change()
    769  self:reset_timer()
    770  if self.debounce > 0 then
    771    self.timer = vim.defer_fn(function()
    772      self:send_request()
    773    end, self.debounce)
    774  else
    775    self:send_request()
    776  end
    777 end
    778 
    779 ---@private
    780 function STHighlighter:reset_timer()
    781  local timer = self.timer
    782  if timer then
    783    self.timer = nil
    784    if not timer:is_closing() then
    785      timer:stop()
    786      timer:close()
    787    end
    788  end
    789 end
    790 
    791 ---@param bufnr (integer) Buffer number, or `0` for current buffer
    792 ---@param client_id (integer) The ID of the |vim.lsp.Client|
    793 ---@param debounce? (integer) (default: 200): Debounce token requests
    794 ---        to the server by the given number in milliseconds
    795 function M._start(bufnr, client_id, debounce)
    796  local highlighter = STHighlighter.active[bufnr]
    797 
    798  if not highlighter then
    799    highlighter = STHighlighter:new(bufnr)
    800    highlighter.debounce = debounce or 200
    801  else
    802    highlighter.debounce = debounce or highlighter.debounce
    803  end
    804 
    805  highlighter:on_attach(client_id)
    806 end
    807 
    808 --- Start the semantic token highlighting engine for the given buffer with the
    809 --- given client. The client must already be attached to the buffer.
    810 ---
    811 --- NOTE: This is currently called automatically by |vim.lsp.buf_attach_client()|. To
    812 --- opt-out of semantic highlighting with a server that supports it, you can
    813 --- delete the semanticTokensProvider table from the {server_capabilities} of
    814 --- your client in your |LspAttach| callback or your configuration's
    815 --- `on_attach` callback:
    816 ---
    817 --- ```lua
    818 --- client.server_capabilities.semanticTokensProvider = nil
    819 --- ```
    820 ---
    821 ---@deprecated
    822 ---@param bufnr (integer) Buffer number, or `0` for current buffer
    823 ---@param client_id (integer) The ID of the |vim.lsp.Client|
    824 ---@param opts? (table) Optional keyword arguments
    825 ---  - debounce (integer, default: 200): Debounce token requests
    826 ---        to the server by the given number in milliseconds
    827 function M.start(bufnr, client_id, opts)
    828  vim.deprecate('vim.lsp.semantic_tokens.start', 'vim.lsp.semantic_tokens.enable(true)', '0.13.0')
    829  vim.validate('bufnr', bufnr, 'number')
    830  vim.validate('client_id', client_id, 'number')
    831 
    832  bufnr = vim._resolve_bufnr(bufnr)
    833 
    834  opts = opts or {}
    835  assert(
    836    (not opts.debounce or type(opts.debounce) == 'number'),
    837    'opts.debounce must be a number with the debounce time in milliseconds'
    838  )
    839 
    840  local client = vim.lsp.get_client_by_id(client_id)
    841  if not client then
    842    vim.notify('[LSP] No client with id ' .. client_id, vim.log.levels.ERROR)
    843    return
    844  end
    845 
    846  if not vim.lsp.buf_is_attached(bufnr, client_id) then
    847    vim.notify(
    848      '[LSP] Client with id ' .. client_id .. ' not attached to buffer ' .. bufnr,
    849      vim.log.levels.WARN
    850    )
    851    return
    852  end
    853 
    854  if
    855    not client:supports_method('textDocument/semanticTokens/full', bufnr)
    856    and not client:supports_method('textDocument/semanticTokens/range', bufnr)
    857  then
    858    vim.notify('[LSP] Server does not support semantic tokens', vim.log.levels.WARN)
    859    return
    860  end
    861 
    862  M._start(bufnr, client_id, opts.debounce)
    863 end
    864 
    865 --- Stop the semantic token highlighting engine for the given buffer with the
    866 --- given client.
    867 ---
    868 --- NOTE: This is automatically called by a |LspDetach| autocmd that is set up as part
    869 --- of `start()`, so you should only need this function to manually disengage the semantic
    870 --- token engine without fully detaching the LSP client from the buffer.
    871 ---
    872 ---@deprecated
    873 ---@param bufnr (integer) Buffer number, or `0` for current buffer
    874 ---@param client_id (integer) The ID of the |vim.lsp.Client|
    875 function M.stop(bufnr, client_id)
    876  vim.deprecate('vim.lsp.semantic_tokens.stop', 'vim.lsp.semantic_tokens.enable(false)', '0.13.0')
    877  vim.validate('bufnr', bufnr, 'number')
    878  vim.validate('client_id', client_id, 'number')
    879 
    880  bufnr = vim._resolve_bufnr(bufnr)
    881 
    882  local highlighter = STHighlighter.active[bufnr]
    883  if not highlighter then
    884    return
    885  end
    886 
    887  highlighter:on_detach(client_id)
    888 
    889  if vim.tbl_isempty(highlighter.client_state) then
    890    highlighter:destroy()
    891  end
    892 end
    893 
    894 --- Query whether semantic tokens is enabled in the {filter}ed scope
    895 ---@param filter? vim.lsp.capability.enable.Filter
    896 function M.is_enabled(filter)
    897  return vim.lsp._capability.is_enabled('semantic_tokens', filter)
    898 end
    899 
    900 --- Enables or disables semantic tokens for the {filter}ed scope.
    901 ---
    902 --- To "toggle", pass the inverse of `is_enabled()`:
    903 ---
    904 --- ```lua
    905 --- vim.lsp.semantic_tokens.enable(not vim.lsp.semantic_tokens.is_enabled())
    906 --- ```
    907 ---
    908 ---@param enable? boolean true/nil to enable, false to disable
    909 ---@param filter? vim.lsp.capability.enable.Filter
    910 function M.enable(enable, filter)
    911  vim.lsp._capability.enable('semantic_tokens', enable, filter)
    912 end
    913 
    914 --- @nodoc
    915 --- @class STTokenRangeInspect : STTokenRange
    916 --- @field client_id integer
    917 
    918 --- Return the semantic token(s) at the given position.
    919 --- If called without arguments, returns the token under the cursor.
    920 ---
    921 ---@param bufnr integer|nil Buffer number (0 for current buffer, default)
    922 ---@param row integer|nil Position row (default cursor position)
    923 ---@param col integer|nil Position column (default cursor position)
    924 ---
    925 ---@return STTokenRangeInspect[]|nil (table|nil) List of tokens at position. Each token has
    926 ---        the following fields:
    927 ---        - line (integer) line number, 0-based
    928 ---        - start_col (integer) start column, 0-based
    929 ---        - end_line (integer) end line number, 0-based
    930 ---        - end_col (integer) end column, 0-based
    931 ---        - type (string) token type as string, e.g. "variable"
    932 ---        - modifiers (table) token modifiers as a set. E.g., { static = true, readonly = true }
    933 ---        - client_id (integer)
    934 function M.get_at_pos(bufnr, row, col)
    935  bufnr = vim._resolve_bufnr(bufnr)
    936 
    937  local highlighter = STHighlighter.active[bufnr]
    938  if not highlighter then
    939    return
    940  end
    941 
    942  if row == nil or col == nil then
    943    local cursor = api.nvim_win_get_cursor(0)
    944    row, col = cursor[1] - 1, cursor[2]
    945  end
    946 
    947  local position = { row, col, row, col }
    948 
    949  local tokens = {} --- @type STTokenRangeInspect[]
    950  for client_id, client in pairs(highlighter.client_state) do
    951    local highlights = client.current_result.highlights
    952    if highlights then
    953      local idx = vim.list.bisect(highlights, { end_line = row }, {
    954        key = function(highlight)
    955          return highlight.end_line
    956        end,
    957      })
    958      for i = idx, #highlights do
    959        local token = highlights[i]
    960        --- @cast token STTokenRangeInspect
    961 
    962        if token.line > row then
    963          break
    964        end
    965 
    966        if
    967          Range.contains({ token.line, token.start_col, token.end_line, token.end_col }, position)
    968        then
    969          token.client_id = client_id
    970          tokens[#tokens + 1] = token
    971        end
    972      end
    973    end
    974  end
    975  return tokens
    976 end
    977 
    978 --- Force a refresh of all semantic tokens
    979 ---
    980 --- Only has an effect if the buffer is currently active for semantic token
    981 --- highlighting (|vim.lsp.semantic_tokens.enable()| has been called for it)
    982 ---
    983 ---@param bufnr (integer|nil) filter by buffer. All buffers if nil, current
    984 ---       buffer if 0
    985 function M.force_refresh(bufnr)
    986  vim.validate('bufnr', bufnr, 'number', true)
    987 
    988  local buffers = bufnr == nil and vim.tbl_keys(STHighlighter.active)
    989    or { vim._resolve_bufnr(bufnr) }
    990 
    991  for _, buffer in ipairs(buffers) do
    992    local highlighter = STHighlighter.active[buffer]
    993    if highlighter then
    994      highlighter:reset()
    995      highlighter:send_request()
    996    end
    997  end
    998 end
    999 
   1000 --- @class vim.lsp.semantic_tokens.highlight_token.Opts
   1001 --- @inlinedoc
   1002 ---
   1003 --- Priority for the applied extmark.
   1004 --- (Default: `vim.hl.priorities.semantic_tokens + 3`)
   1005 --- @field priority? integer
   1006 
   1007 --- Highlight a semantic token.
   1008 ---
   1009 --- Apply an extmark with a given highlight group for a semantic token. The
   1010 --- mark will be deleted by the semantic token engine when appropriate; for
   1011 --- example, when the LSP sends updated tokens. This function is intended for
   1012 --- use inside |LspTokenUpdate| callbacks.
   1013 ---@param token (table) A semantic token, found as `args.data.token` in |LspTokenUpdate|
   1014 ---@param bufnr (integer) The buffer to highlight, or `0` for current buffer
   1015 ---@param client_id (integer) The ID of the |vim.lsp.Client|
   1016 ---@param hl_group (string) Highlight group name
   1017 ---@param opts? vim.lsp.semantic_tokens.highlight_token.Opts  Optional parameters:
   1018 function M.highlight_token(token, bufnr, client_id, hl_group, opts)
   1019  bufnr = vim._resolve_bufnr(bufnr)
   1020  local highlighter = STHighlighter.active[bufnr]
   1021  if not highlighter then
   1022    return
   1023  end
   1024 
   1025  local state = highlighter.client_state[client_id]
   1026  if not state then
   1027    return
   1028  end
   1029 
   1030  local priority = opts and opts.priority or vim.hl.priorities.semantic_tokens + 3
   1031 
   1032  set_mark(bufnr, state.namespace, token, hl_group, priority)
   1033 end
   1034 
   1035 --- |lsp-handler| for the method `workspace/semanticTokens/refresh`
   1036 ---
   1037 --- Refresh requests are sent by the server to indicate a project-wide change
   1038 --- that requires all tokens to be re-requested by the client. This handler will
   1039 --- invalidate the current results of all buffers and automatically kick off a
   1040 --- new request for buffers that are displayed in a window. For those that aren't, a
   1041 --- the BufWinEnter event should take care of it next time it's displayed.
   1042 function M._refresh(err, _, ctx)
   1043  if err then
   1044    return vim.NIL
   1045  end
   1046 
   1047  for bufnr in pairs(vim.lsp.get_client_by_id(ctx.client_id).attached_buffers or {}) do
   1048    local highlighter = STHighlighter.active[bufnr]
   1049    if highlighter and highlighter.client_state[ctx.client_id] then
   1050      highlighter:mark_dirty(ctx.client_id)
   1051 
   1052      if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then
   1053        -- some LSPs send rapid fire refresh notifications, so we'll debounce them with on_change()
   1054        highlighter:on_change()
   1055      end
   1056    end
   1057  end
   1058 
   1059  return vim.NIL
   1060 end
   1061 
   1062 local namespace = api.nvim_create_namespace('nvim.lsp.semantic_tokens')
   1063 api.nvim_set_decoration_provider(namespace, {
   1064  on_win = function(_, _, bufnr, topline, botline)
   1065    local highlighter = STHighlighter.active[bufnr]
   1066    if highlighter then
   1067      highlighter:on_win(topline, botline)
   1068    end
   1069  end,
   1070 })
   1071 
   1072 --- for testing only! there is no guarantee of API stability with this!
   1073 ---
   1074 ---@private
   1075 M.__STHighlighter = STHighlighter
   1076 
   1077 -- Semantic tokens is enabled by default
   1078 vim.lsp._capability.enable('semantic_tokens', true)
   1079 
   1080 return M