什么是 LSP(Language Server Protocol)?

LSP 如何工作?

neovim 是怎么支持 LSP 的以及要如何配置 LSP?

nvim-lspconfig,mason 等插件的安装配置。

LSP

每个 IDE 都要为相关语言实现类似自动补全、转到定义、悬停单词查看文档、转到引用等功能。传统上,必须为每个 IDE 重复这项工作,因为每个 IDE 都提供不同的 API 来实现相同的功能。

语言服务器旨在提供特定于某种语言的智能分析,并且该服务器可以通过支持进程间通信的协议与 IDE 进行通信。

语言服务器协议 (LSP) 的目标是标准化 IDE 和语言服务器通信的协议,因此单个语言服务器可以在多个 IDE 中重复使用,并且 IDE 也可以以最小的努力支持语言。

LSP 是一个协议,不是具体的实现,它规定了语言服务器应当实现的一些规范条件。比如编码格式、数据结构(Text Documents,Position,Range,Location,……)、生命周期(Server lifetime)以及服务器可支持哪些语言特性(Language Features),如 completion,hover,definition,codelens 等等。

语言服务器可以使用任何语言编写,只需要支持进程间通信,并支持 LSP 即可。IDE 只需要基于进程间通信的方式和语言服务器进行通信,并解析标准化格式JSON-RPC的 json 响应数据。

工作原理

语言服务器作为独立进程运行,同时与开发工具使用基于 JSON-RPC 的远程过程调用协议与服务器进行通信。

下面是一个开发工具在运行编辑期间和语言服务器通信的示例: aaa

  1. 用户在开发工具中打开文档:该工具会通知语言服务器文档已打开(textDocument/didOpen)。此时文件内容已被加载到开发工具的内存中,用于和语言服务器实时同步。
  2. 用户进行编辑:该工具通知语言服务器文档更改(textDocument/didChange),程序语义信息由语言服务器更新。发生这种情况时,语言服务器会分析此信息,并通知开发工具(textDocument/publishDiagnostics)检测到的错误和警告。
  3. 用户对编辑器的符号执行“转到定义”:该工具发送具有两个参数的textDocument/definition请求: (1) 文档 URI, (2) 从服务器启动 Go to Definition 请求的文本位置。 服务器使用文档 URI 和符号定义在文档中的位置进行响应。
  4. 用户关闭文档 (文件) :工具发送textDocument/didClose通知,通知语言服务器文档现在不再处于内存中,并且当前内容现在在文件系统上是最新的。

下面是在客户端工具处理 C++ 文档中“转到定义”请求的语言服务器之间传递的有效负载。

客户端工具请求:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "textDocument/definition",
  "params": {
    "textDocument": {
      "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/use.cpp"
    },
    "position": {
      "line": 3,
      "character": 12
    }
  }
}

语言服务器响应:

{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/provide.cpp",
    "range": {
      "start": {
        "line": 0,
        "character": 4
      },
      "end": {
        "line": 0,
        "character": 11
      }
    }
  }
}

描述编辑器级别而不是编程语言模型级别的数据类型是语言服务器协议成功的原因之一。

交互方式

当用户使用不同语言时,开发工具通常会为每个编程语言启动语言服务器。

下面是 VSCode 启用 Java 和 Sass 服务器示例: Java 和 Sass的示例

功能

并非每个语言服务器都支持协议定义的所有功能。因此,客户端和服务器通过capabilities宣布其支持的功能集。 例如,服务器会宣布它可以处理“textDocument/definition”请求,但它可能无法处理“workspace/symbol”请求。 同样,客户端可以宣布,他们可以在保存文档之前提供“即将保存”通知,以便服务器可以计算文本编辑以自动设置编辑文档的格式。

语言服务器与特定工具的实际集成不是由语言服务器协议定义的,而是留给工具实现者的。

SDK

  1. 开发工具 SDK:每个开发工具通常都提供一个用于集成语言服务器的库。 例如,对于 JavaScript/TypeScript,有语言客户端 npm 模块。
  2. 语言服务器 SDK:对于不同的实现语言,都有一个 SDK 可以用特定语言实现语言服务器。 例如,要使用 Node.js 实现语言服务器,可以使用语言服务器 npm 模块。

这是 C/C++的语言服务器clangd

NVIM

Nvim 提供了 LSP 的客户端,但是语言服务器需要第三方来支持。

对于 Nvim 来说,可以通过以下步骤来获得 LSP 功能:

  1. 安装语言服务器,可以用的语言服务器

  2. 配置语言服务器:

    vim.lsp.start({
      name = 'my-server-name',
      cmd = {'name-of-language-server-executable'},
      root_dir = vim.fs.dirname(vim.fs.find({'setup.py', 'pyproject.toml'}, { upward = true })[1]),
    })
    
  3. 配置键盘映射和自动命令以利用 LSP 功能。

并非所有语言服务器都提供相同的功能。在绑定快捷键之前可以对语言服务器支持的功能进行检测,检测代码如下:

vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    if client.server_capabilities.hoverProvider then
      vim.keymap.set('n', 'K', vim.lsp.buf.hover, { buffer = args.buf })
    end
  end,
})

启动 LSP 客户端将通过 vim.diagnostic 自动报告 diagnostics,可以对 diagnostic 进行自定义配置vim.diagnostic.config

可以通过 nvim 插件nvim-lspconfig,对 LSP 功能进行配置。

Nvim 的 LSP 功能使用 omnifunc 对代码提供补全功能,需要手动触发,可以使用插件nvim-cmp提供自动不全功能。

下面附带一份 nvim-lspconfig 的配置代码

local installStatus = pcall(require, "lspconfig")
local cmplspStatus = pcall(require, "cmp_nvim_lsp")

if installStatus == false then
    vim.notify("没有找到lspconfig")
    return
end

if cmplspStatus == false then
    vim.notify("没有找到cmp_nvim_lsp")
    return
end

-- Use an on_attach function to only map the following keys
-- after the language server attaches to the current buffer
local on_attach = function(client, bufnr)
    -- 是否启用手动触发代码完成
    -- vim.api.nvim_buf_set_option(bufnr, "omnifunc", "v:lua.vim.lsp.omnifunc")

    -- disable formatting
    client.server_capabilities.document_formatting = false
    client.server_capabilities.document_range_formatting = false

    -- 自定义绑定到vim.lsp.buf的键映射
    -- See `:help vim.lsp.*` for documentation on any of the below functions
    local bufopts = { noremap = true, silent = true, buffer = bufnr }
    vim.keymap.set("n", "gD", vim.lsp.buf.declaration, bufopts)

    vim.keymap.set("n", "gd", vim.lsp.buf.definition, bufopts)
    vim.keymap.set("n", "K", vim.lsp.buf.hover, bufopts)
    if client.server_capabilities.implementationProvider then
        vim.keymap.set("n", "gi", vim.lsp.buf.implementation, bufopts)
    end

    vim.keymap.set("n", "<C-k>", vim.lsp.buf.signature_help, bufopts)
    vim.keymap.set("n", "<space>wa", vim.lsp.buf.add_workspace_folder, bufopts)
    vim.keymap.set("n", "<space>wr", vim.lsp.buf.remove_workspace_folder, bufopts)
    vim.keymap.set("n", "<space>wl", function()
        print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
    end, bufopts)
    vim.keymap.set("n", "<space>D", vim.lsp.buf.type_definition, bufopts)
    vim.keymap.set("n", "<space>rn", vim.lsp.buf.rename, bufopts)
    vim.keymap.set("n", "<space>ca", vim.lsp.buf.code_action, bufopts)
    vim.keymap.set("n", "gr", vim.lsp.buf.references, bufopts)

    vim.keymap.set("n", "<space>f", function()
        vim.lsp.buf.format({ async = true })
    end, bufopts)
end

-- 增加nvim-cmp支持的额外的capabilities
-- 为了增强nvim默认的omnifunc的候选菜单

local capabilities = require("cmp_nvim_lsp").default_capabilities()
local clangd_capabilities = require("cmp_nvim_lsp").default_capabilities()
clangd_capabilities.offsetEncoding = "utf-8"

local lsp_flags = {
    -- This is the default in Nvim 0.7+
    debounce_text_changes = 150,
}
require("lspconfig")["clangd"].setup({
    single_file_support = true,
    on_attach = on_attach,
    capabilities = clangd_capabilities,
    flags = lsp_flags,
})