深入分析 LSP 协议
Emacs
LSP
2024-06-11 2576字

lsp-bridge 开发了两年多, 每每研究 issue 时, 依然发现 LSP 这个协议有很多知识需要学习。

如果你之前没有听说过, 可以先读一下我以前写的文章:

今天分享的内容主要涉及到 LSP 协议的一些深入细节, 很多和特定语言的 LSP Server 实现有关:

动态 languageId

LSP 协议要求每打开一个文件时, LSP Client 需要先发送 textDocument/didOpen 请求给 LSP server, 一般来说, 每个 LSP Server 对应一个 languageId, 比如 Python 的 LSP Server pyright 要求的 languageId 是 python

但是, 唯一的例外就是 TailwindCSS 这个 LSP server, TailwindCSS 因为支持多种文件格式 (比如 css, js, vue, svelte 等, 完整格式参考), TailwindCSS 会根据所分析的文件动态的去调整 languageId, 如果 LSP client 在发送 textDocument/didOpen 消息时所附带的 languageId 和 TailwindCSS 期望的 languageId 不一致, TailwindCSS 就会拒绝工作。

甚至在一些前端项目, 一个同样扩展名的文件在不同项目中的 languageId 都是不一样的, 这时候就需要用户根据项目的路径来定制 languageId。

这个 bug 困扰了我 1 年多, 最后还是一个国外开发者发现了这个 trick , 可以认真读一下相关的内容, 加深对 TailwindCSS 的理解。

反编译型语言服务器

大部分 LSP server 当你发送 textDocument/definition 请求时, 它会直接返回光标处的源代码定义。

但是一些 LSP server 的行为不一致, 比如 Java、 C#、 Deno 等, 这些语言服务器符合一个特征 反编译查找代码定义:

  1. 当我们发送 textDocument/definition 请求时, LSP server 首先返回特殊的 URI, 比如 jdtls 在查找 Java 代码定义时, 会返回 jdt:// 开头的 URI 给 LSP Client
  2. LSP client 需要拿着这个 URI 再次向 jdtls 发送一个 java/classFileContents 请求后, LSP server 才会返回代码定义的源代码 (csharp-ls 是 csharp/metadata, Deno 是 deno/virtualTextDocument)

我把符合这种代码查找行为的 LSP server 称为 反编译型语言服务器

一般来说, LSP server 返回定义的源代码, 我们存到临时文件就完了, 但是对于 LSP client 开发来说, 还远远不够:

如果我们针对这些反编译后的临时文件的代码内容再进行二次或多次代码定位时, LSP server 就会停止工作, 这是因为一般的 LSP client 会根据文件的路径启用不同的 LSP server 实例来处理 LSP 请求, 恰恰是这种行为导致新启动的 LSP server 无法获取临时文件之前的项目代码结构信息, 从而无法完成对临时文件的代码定义跳转。

所以, 像 lsp-bridge 这种 二班 的 LSP client 会聪明的知道这些临时文件的来源, 让初始查找定义的文件和后面反编译生成的临时文件共享一个 LSP server, 共享后 LSP server 就知道这些反编译临时文件前世今生, 就可以继续工作啦。

lsp-bridge 内部是用 external_file_link 机制来实现上面的方案的, 可以在 lsp-bridge 代码中搜索 external_file_link 来了解代码逻辑流程。

项目根目录设置

大多数 LSP server 在 LSP client 发送 initialize 请求时就会根据携带的 rootUri 参数来确定项目的根目录所在位置。

但是, csharp-ls 这个 LSP server 的实现就比较奇怪, initialize 请求发送的 rootUri 对于 csharp-ls 来说就是失忆状态, 需要在 workspace/didChangeConfiguration 请求中再次设置 settings.csharp.solution 才行, 晚一点的 workspace/configuration 请求都不行, 因为晚了, csharp-ls 就会放飞自我, 按照当前文件所在的目录去查找 *.sln 或者 *.csproj 文件, 当然是找不到啦, 最后 csharp-ls 就发脾气不干了。

所以, 适配 csharp-ls 语言服务器的时候一定要注意这些细节。

workspace/configuration 怪癖

当 LSP server 发送 workspace/configuration 请求和 LSP client 进行工作区配置协商时, 如果 LSP client 发现满足 LSP server 的要求表示同意时, 只用在响应中回复一个 [] 空列表即可。

但是 zig 的语言服务器 zls 偏不, 它需要按照 workspace/configuration 发送 params 的长度要求 LSP client 返回同等长度的 null 数组, 如果数量不对, zls 会直接崩溃。 :(

还有一些服务器, 需要 LSP client 在回应 workspace/configuration 请求时动态添加一些额外信息, 比如 vscode-eslint-language-server 要求增加 workspaceFolder 信息, graphql-lsp 需要增加 load.rootDir 信息, 才能继续工作。

总之, workspace/configuration 协议的回应并没有统一的标准, 需要 LSP client 开发时根据 LSP server 的文档做很多适配工作。

返回响应格式不一

LSP 协议标准对于 LSP server 返回的响应内容并没有那么严格的标准约束, 比如获取补全项文档的 completionItem/resolve 协议以及获取光标处函数参数的 textDocument/hover 协议, 不同的 LSP server 返回的格式五花八门, 需要 LSP client 针对不同的 LSP server 一个一个的做兼容适配。

最后

上面就是开发 LSP client 过程中遇到的一些深坑, 互联网上基本没有 LSP 协议分析的文章, 希望上面的内容可以帮助同是开发 LSP client 的同学节省一些时间。