Как улучшить LSP поиск референсов в Neovim

Сегодня расскажу о том, как я улучшил свой опыт использования LSP функции textDocument/references в Neovim.

Описанное решение актуально для Neovim >= 0.11.0. Я проверил его работоспособность на v0.11.1. В качестве языка для демонстрации используется Go (LSP-сервер gopls). Всё будет работать аналогично и для других LSP-серверов.

Я не буду подробно описывать, что такое LSP. Почитать об этом можно в тут: https://habr.com/ru/companies/sberbank/articles/838786/

Это - база copy

После релиза Neovim v0.11.0 для подключения и базовой настройки LSP нужно добавить в конфиг буквально пару команд. На примере gopls:

vim.lsp.config("gopls", {
  cmd = { "gopls", "serve" },
  filetypes = { "go", "go.mod" },
  root_markers = { "go.work", "go.mod", ".git" },
})

vim.lsp.enable "gopls"

Чтобы вызвать функцию поиска референсов (LSP textDocument/references), можно использовать дефолтный хоткей grr в нормальном режиме.

Проблемы copy

Проблема №0 copy

Дефолтный хоткей для меня абсолютно неудобен.

Полагаю, при придумывании мапинга grr разработчики Neovim руководствовались следующей логикой:

  • g - одна из немногих незанятых в нормальном режиме по умолчанию клавиш. На неё в Vim/Neovim навешано много разных функций “из коробки”
  • rr - сокращение от слова RefeRences (по аналогии grn - ReName )

Мне неудобно нажимать хоткей, состоящий из трёх клавиш, одним пальцем. Особенно если в нём две соседние клавиши - одинаковые.

В конфиге, который я писал ещё под Neovim 0.10.4 (когда дефолтных маппингов для LSP функций ещё не было) я замапил функцию поиска референсов на gk.

Проблема №1 copy

В списке референсов есть определение искомого объекта.

Ситуация: я вижу в коде использование функции и хочу понять, где ещё она используется. Когда я вызываю функцию textDocument/references, в полученном списке помимо вызовов искомой функции я вижу строчку, в которой эта функция определена.

Такое поведение мне не нравится. Если я захочу перейти к определению функции/переменной/…, я использую textDocument/definition. Под референсами мне привычнее понимать именно использования объекта, но не его определение.

Проблема №2 copy

В списке референсов есть объект, на котором была вызвана функция поиска.

Ситуация: я вижу в коде использование функции и хочу понять, где ещё она используется. Когда я вызываю функцию textDocument/references, в полученном списке помимо прочих вызовов я вижу и то использование, “стоя” курсором на котором я нажал хоткей для поиска референсов.

Проблема №3 copy

Список рефенерсов открывается через QuickfixList, даже если в нём один элемент.

Ситуация: я вижу в коде определение функции и хочу понять, где она используется. Когда я нажимаю хоткей для поиска референсов, возможны следующие ситуации:

  • Если референсы не найдены, появится строка "No references found"
  • Если найден хоты бы один референс, откроется QuickfixList с найденными элементами

При этом, как правило, сразу после поиска референсов я перехожу к одному из них. Следовательно, если референс всего один, этот переход можно автоматизировать и избавится от лишнего нажатия клавиши в QuickfixList.

Решение copy

Проблема №0 в Neovim 0.11.0+ решается “классическим” способом - определением кастомных маппингов в функции on_attach. Так обычно задавались маппинги для LSP в версиях Neovim до 0.11.0

Для решения проблемы №1 в спецификации LSP у textDocument/references определён параметр includeDeclaration. Он определяет, нужно ли включить определение объекта в результат функции. По умолчанию = true.

Проблемы №2 и №3 интереснее, поскольку их невозможно решить путём передачи каких-либо параметров - таковые не предусмотрены в Neovim “из коробки”.

Чтобы добиться желаемого поведения, я определил собственную функцию для поиска референсов. Я взял реализацию дефолтной фукнции поиска референсов из исходников Neovim и немного доработал её.

Вот что у меня получилось в итоге:

-- Прочие настройки
-- ...
require "lsp"
local utils = require "lsp.utils"

vim.lsp.config("gopls", {
  cmd = { "gopls", "serve" },
  filetypes = { "go", "go.mod" },
  root_markers = { "go.work", "go.mod", ".git" },
  on_init = utils.on_init,
  on_attach = utils.on_attach,
})

vim.lsp.enable "gopls"
local M = {}

local map = vim.keymap.set

M.on_init = function(client, _)
  if client.supports_method "textDocument/semanticTokens" then
    client.server_capabilities.semanticTokensProvider = nil
  end
end

M.on_attach = function(client, bufnr)
  local function map_opts(desc)
    return { buffer = bufnr, desc = "LSP: " .. desc }
  end

  local floating_window_opts = {
    border = "single",
    max_width = 80,
  }

  map("n", "gD", vim.lsp.buf.declaration, map_opts "go to declaration")
  map("n", "gd", vim.lsp.buf.definition, map_opts "go to definition")
  map("n", "gi", vim.lsp.buf.implementation, map_opts "go to implementation")
  map("n", "gj", vim.lsp.buf.type_definition, map_opts "go to type definition")

  map("n", "gk", function()
    require("lsp.handlers").references { includeDeclaration = false }
  end, map_opts "show references")

  map("n", "K", function()
    return vim.lsp.buf.hover(floating_window_opts)
  end, map_opts "hover")

  map("n", "<leader>ld", function()
    return vim.diagnostic.open_float(floating_window_opts)
  end, map_opts "diagnostic open float")

  map("n", "<leader>ls", function()
    return vim.lsp.buf.signature_help(floating_window_opts)
  end, map_opts "show signature help")

  map("i", "<c-s>", function()
    return vim.lsp.buf.signature_help(floating_window_opts)
  end, map_opts "show signature help")

  map("n", "<leader>lr", vim.lsp.buf.rename, map_opts "rename")

  map({ "n", "v" }, "<leader>la", vim.lsp.buf.code_action, map_opts "code action")
  map({ "n", "v" }, "<leader>ll", vim.lsp.codelens.run, map_opts "code lens")
end

return M
local M = {}

local util = require "vim.lsp.util"
local ms = require("vim.lsp.protocol").Methods

local filter_references = function(result)
  if result == nil or #result == 0 then
    return {}
  end

  local current_position = vim.api.nvim_win_get_cursor(0)
  local current_uri = vim.uri_from_bufnr(0)

  local filtered_result = {}

  for _, ref in ipairs(result) do
    if
      ref.user_data.uri ~= current_uri
      or ref.user_data.range.start.line ~= current_position[1] - 1
      or ref.user_data.range.start.character > current_position[2]
    then
      table.insert(filtered_result, ref)
    end
  end

  return filtered_result
end

local function on_done(client, items, bufnr)
  items = filter_references(items)

  if not next(items) then
    vim.notify "No references found"
    return
  end

  if #items == 1 then
    vim.lsp.util.show_document({
      uri = items[1].user_data.uri,
      range = items[1].user_data.range,
    }, client.offset_encoding, { reuse_win = true, focus = true })
    return
  end

  local list = {
    title = "References",
    items = items,
    context = {
      method = ms.textDocument_references,
      bufnr = bufnr,
    },
  }

  vim.fn.setloclist(0, {}, " ", list)
  vim.cmd.lopen()
end

M.references = function(context)
  local bufnr = vim.api.nvim_get_current_buf()
  local clients = vim.lsp.get_clients { method = ms.textDocument_references, bufnr = bufnr }
  if not next(clients) then
    return
  end

  local win = vim.api.nvim_get_current_win()

  local all_clients_items = {}

  local remaining = #clients
  for _, client in ipairs(clients) do
    local params = util.make_position_params(win, client.offset_encoding)

    ---@diagnostic disable-next-line: inject-field
    params.context = context or {
      includeDeclaration = true,
    }

    client:request(ms.textDocument_references, params, function(_, result)
      local client_items = util.locations_to_items(result or {}, client.offset_encoding)
      vim.list_extend(all_clients_items, client_items)
      remaining = remaining - 1
      if remaining == 0 then
        on_done(client, all_clients_items, bufnr)
      end
    end)
  end
end

return M
  • filter_references - фильтрует результаты поиска референсов, исключая из них тот референс, на котором была вызвана фукнция
  • if #items == 1 then ... - автоматический переход к референсу, если он в списке всего один

Мой конфиг Neovim: https://github.com/Yu-Leo/nvim

читайте также