200 行 Clojure 代码实现的 LSP 客户端
(:dev/notes vlaaad)
2025年5月10日
200 行 Clojure 代码实现的 LSP 客户端
不久前,我曾尝试将 LLM 与 LSP 集成,以便让语言模型能够回答关于代码的问题,同时还能访问由语言服务器提供的代码导航工具。虽然这个原型并没有取得太大的成功,但我发现用大约 200 行代码编写一个最简的 LSP 客户端非常酷。当然,这很大程度上得益于我之前为 Defold 编辑器 编写了一个功能更丰富的 LSP 客户端... 所以,让我与你分享一个用 Clojure 编写的,少于 200 行的最简 LSP 客户端。此外,在文章的最后,我还会分享我对 LSP 的一些看法。 这篇文章的目标读者是谁呢?我也不知道... 编写代码编辑器的 Clojure 开发者?大概只有 3 个吧!好吧,让我们稍微改变一下这个练习的范围:让我们构建一个命令行 linter,它使用语言服务器来完成工作。这应该不成问题...
内容概要
首先是一些术语和范围。LSP 代表 Language Server Protocol(语言服务器协议),它是一种标准,定义了文本编辑器(语言客户端)应该如何与特定语言的工具(语言服务器)进行通信,后者了解编程语言的语义,并可能提供上下文信息,如代码导航、重构、linting 等。 LSP 的主要好处是,IDEs 和语言的所谓的 MxN 问题变成了 M+N。 这里有一个很好的解释。简而言之,作为语言作者,以前你必须为每个代码编辑器编写集成。或者,作为 IDE 作者,你必须为每种语言编写单独的集成。现在有了一个通用的接口——LSP——语言作者和 IDE 作者只需要支持这个接口。 在 200 行代码中,我们将实现 LSP 规范 的基本模块,该规范支持对语言服务器进行编程只读查询。我们将实现:
- 语言客户端和服务器进程之间的基本通信层。它类似于 HTTP 协议:客户端和服务器使用字节流进行通信,消息格式为 headers + JSON message bodies。基本层建立了一种交换 JSON blobs 的方式。
- JSON-RPC ——位于基本层之上的层,为 JSON blobs 添加含义,将它们转换为 requests/responses 或 notifications。
- JSON-RPC 连接的包装器,这是一个我们可以与之交谈的鲜活的语言服务器。
我们将使用 Java 24 和 virtual threads:编写执行和扩展良好的阻塞代码是令人愉悦、美好且高效的。现在,这里有一些我们不会实现的东西:
- JSON parser。我的意思是,拜托。我们只需要一个依赖项。我选择了 jsonista,因为它速度快而且名字很酷。
- Document syncing。当用户在文本编辑器中打开一个文件并对其进行一些更改而不保存时,编辑器会通知正在运行的语言服务器有关打开文件的新文本。我们在这里不是构建文本编辑器,只是一个小的 PoC,所以我们将跳过这一步。
现在,开始吧!
实现方式
如果你只是想看代码,这里是。现在我将带你了解它。
基本层
首先,我们从基本通信层开始。语言服务器在另一个进程中运行,因此通信通过 InputStream + OutputStream 对进行。我们将以进程的方式运行语言服务器,并将通过 stdin/stdout 进行通信,因此 java Process 将为我们提供该对。客户端和服务器都发送和接收带有 JSON blobs 的类似 HTTP 的请求。每个单独的消息看起来像这样:
Content-Length: 14\r\n
\r\n
{"json": true}
首先,有 1 个或多个 headers,其中包含必需的 Content-Length
header,用 \r\n
分隔。然后,一个空行。然后是 JSON 字符串。headers 使用 ASCII 编码进行序列化(因此 1 个字节始终是 1 个字符),JSON blob 使用 UTF-8。
我们从 一个从 InputStream 读取 ascii 文本行的函数 开始:
(defn-read-ascii-line[^InputStreamin](let[sb(StringBuilder.)](loop[carriage-returnfalse](let[ch(.readin)](if(=-1ch)(if(zero?(.lengthsb))nil(.toStringsb))(let[ch(charch)](.appendsbch)(cond(=ch\return)(recurtrue)(andcarriage-return(=ch\newline))(.substringsb0(-(.lengthsb)2)):else(recurfalse))))))))
因此,我们将字符逐字节地读入字符串,直到到达 \r\n
。如果我们到达流的末尾,我们返回 nil
。我们不能在这里使用 BufferedReader 的 readLine
,原因有几个:
- 它会缓冲,这意味着它可能会读取超过我们想要的内容。
- 它使用
\n
和\r\n
作为行分隔符,而我们只需要\r\n
。 - 它使用单个编码,而通信通道使用 ASCII 和 UTF-8 的混合。
下一步是实现整个 基本通信层 的单个函数:
(defn-lsp-base[^InputStreamin^BlockingQueueserver-in^OutputStreamout^BlockingQueueserver-out](->(Thread/ofVirtual)(.name"lsp-base-in")(.start#(loop[](when-some[headers(loop[acc{}](when-let[line(read-ascii-linein)](if(=""line)acc(if-let[[_fieldvalue](re-matches#"^([^:]+):\s*(.+?)\s*$"line)](recur(assocacc(string/lower-casefield)value))(throw(IllegalStateException.(str"Can't parse header: "line)))))))](let[^Stringcontent-length(or(getheaders"content-length")(throw(IllegalStateException."Required header missing: Content-Length")))len(Integer/valueOfcontent-length)bytes(.readNBytesinlen)](if(=(alengthbytes)len)(do(.putserver-in(json/read-value(String.bytesStandardCharsets/UTF_8)json/keyword-keys-object-mapper))(recur))(throw(IllegalStateException."Couldn't read enough bytes"))))))))(->(Thread/ofVirtual)(.name"lsp-base-out")(.start#(whiletrue(let[^bytesmessage-bytes(json/write-value-as-bytes(.takeserver-out))](dotoout(.write(.getBytes(str"Content-Length: "(alengthmessage-bytes)"\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n")StandardCharsets/UTF_8))(.writemessage-bytes)(.flush)))))))
此函数将 client/server 通信从 InputStream+OutputStream 对(字节)转换为 json blobs 的 input+output BlockingQueues。 "lsp-base-in"
部分从 InputStream 读取 headers,然后读取 JSON 对象,最后将其放入 server-in
队列。这样,每当语言服务器向我们发送某些内容时,我们都会在队列中收到它作为 JSON。 "lsp-base-out"
是一个逆过程:它从 server-out
读取 JSON 对象并将它们写入服务器。这样,当我们想要向语言服务器发送消息时,我们只需要将 JSON 值放入 server-out
队列。
JSON-RPC 层
LSP 客户端和服务器以一种特殊的格式交换 JSON blobs,称为 JSON-RPC。主要思想是就交换数据的形状和含义达成一致,以便交换 JSON 对象支持以下用例:
- 发送一个请求来执行一个特定的动作,并接收这个请求的响应 (即 "remote procedure call")
- 发送一个不期望响应的 notification
这个用例是通过交换带有特殊字段组合的 JSON 对象来实现的,即:
- 要发送一个请求,使用一个带有
id
(请求标识符) 和method
(动作标识符) 字段的 JSON 对象。可选地,你可以提供params
,即 "method call" 的 "argument"。 - 要发送一个 notification,使用一个请求,但是没有
id
字段 - 为了响应一个请求,发送一个带有收到的请求的
id
的 JSON 对象,以及一个error
或result
字段,这取决于我们是否遇到了错误或成功地生成了一个结果。该错误必须是一个带有code
和message
字段的对象。
现在我将带你了解 JSON-RPC 协议的实现,这恰好是 一个函数。 我们从这个参数列表开始:
(defn-lsp-jsonrpc[^BlockingQueueclient-in^BlockingQueueserver-in^BlockingQueueserver-outhandlers]...)
server-in
和 server-out
是 LSP commucation 的基本层。我们将 JSON-RPC 对象放入 server-out
以向语言服务器发送消息。我们将从 server-in
读取以从语言服务器接收语言服务器 JSON-RPC 对象。那么,什么是 client-in
和 handlers
呢?
client-in
是另一个队列,我们将使用它来向语言服务器发送 requests 和 notifications。我们的 lsp-jsonrpc
函数将从 client-in
中获取对象,执行一些预处理,然后将 JSON-RPC 对象发布到 server-out
。这将使我们能够编写一个简单的 API 来向语言服务器发送消息。
handler
是一个从 JSON-RPC "method name" 到一个函数的映射。当语言服务器决定通知我们关于某事时,或者向我们发送一个请求时,我们将查找一个函数来处理 handlers
映射中的这个通知。这使我们能够响应来自语言服务器的请求。
函数中的下一段代码将 client-in
和 server-in
"合并" 成一个队列 (in
):
(let[in(SynchronousQueue.)](->(Thread/ofVirtual)(.name"lsp-jsonrpc-client")(.start#(whiletrue(.putin[:client(.takeclient-in)]))))(->(Thread/ofVirtual)(.name"lsp-jsonrpc-server")(.start#(whiletrue(.putin[:server(.takeserver-in)]))))...)
现在,我们可以编写一个单独的顺序循环,该循环从 in
中获取消息并处理来自 "我们",即客户端和 "他们",即远程语言服务器的消息。有了 virtual threads,这个阻塞代码仍然是轻量级和高性能的。顺便说一句,我认为 core.async 在 JDK 24 之后存在的唯一原因是 flow 提供的可观测性工具。并且,也许,滑动缓冲区——据我所知,JDK 中没有它们的阻塞替代方案。
好的,让我们继续。JSON-RPC 实现中的下一段代码是循环:
(->(Thread/ofVirtual)(.name"lsp-jsonrpc")(.start#(loop[next-id0requests{}](let[[srcmessage](.takein)](casesrc...)))))
我们启动另一个轻量级进程,该进程处理来自语言服务器和客户端的传入消息。我们需要 next-id
和 requests
来支持发送请求,然后处理这些请求的传入响应。我们正在从 in
中获取,所以 src
是 :client
或 :server
,而 message 是一个 JSON-RPC 消息。现在,让我们开始处理东西!首先,我们将处理 :client
情况,即我们发送到服务器的消息:
:client(let[out-message(cond->{:jsonrpc"2.0":method(:methodmessage)}(contains?message:params)(assoc:params(:paramsmessage)))](if-let[response-queue(:responsemessage)](do(.putserver-out(assocout-message:idnext-id))(recur(incnext-id)(assocrequestsnext-idresponse-queue)))(do(.putserver-outout-message)(recurnext-idrequests))))
请记住,我们需要支持 notifications(不期望响应)和 requests(需要响应)。我们将通过 client messages 上的 :response
键来区分它们。该键的值将是一个 BlockingQueue
—— 一旦我们收到来自语言服务器的响应,我们将把响应值放入这个队列。如果我们正在发送一个响应,我们递增 next-id
计数器,并将等待响应的队列存储在 in-flight requests
映射中。如果我们正在发送一个 notification,我们只需发送一个 JSON-RPC 对象并继续。
客户端就是这样!现在我们处理来自服务器的传入消息。有 3 种可能的消息类型:
- 对我们的请求的响应:这些消息具有
id
以及result
或error
。 - notifications:这些消息具有
method
,但没有id
- requests:这些消息同时具有
method
和id
这是 :server
情况:
:server(cond;; response?(and(contains?message:id)(or(contains?message:result)(contains?message:error)))(let[id(:idmessage)^BlockingQueueresponse-out(getrequestsid)](.putresponse-outmessage)(recurnext-id(dissocrequestsid)));; notification?(and(contains?message:method)(not(contains?message:id)))(do(when-let[handler(gethandlers(:methodmessage))](handler(:paramsmessage)))(recurnext-idrequests));; request?(and(contains?message:method)(contains?message:id))(do(.putserver-out(try{:jsonrpc"2.0":id(:idmessage):result((gethandlers(:methodmessage))(:paramsmessage))}(catchThrowablee{:jsonrpc"2.0":id(:idmessage):error{:code-32603:message(or(ex-messagee)"Internal Error")}})))(recurnext-idrequests)):else(do(.putserver-out{:jsonrpc"2.0":id(:idmessage):error{:code-32600:message"Invalid Request"}})(recurnext-idrequests))))))))))
当我们收到对我们的请求的响应时,我们将其放入存储在 in-flight requests
映射中的队列中,并从映射中删除该队列。当我们收到一个 notification 时,如果它存在,我们只需调用该 handler。处理 requests 有点不同,因为我们要确保服务器始终会收到响应。所以我们做一个 try/catch 并始终发回一些东西。我们在 JSON-RPC 进程线程上进行请求处理,因此如果它长时间阻塞,则不会处理其他消息。这实际上是一个缺点。所以让我们说我为了说明目的而简化了事情,并且生成另一个 virtual thread 来计算并向服务器发送响应留给读者作为练习:D
最后,有一个 :else
分支,用于响应带有错误的意外消息。鉴于其他地方缺乏错误处理和验证,我认为这是不必要的防御性的。
API
现在所有通信都已实现,是时候创建一个 API 了。我们只需要 3 个函数:
start!
启动一个语言服务器。request!
向语言服务器发送一个请求并获得一个结果notify!
向语言服务器发送一个 notification 并且什么也不返回
让我们从 start!
-ing 一个服务器开始:
(defnstart!([^Processprocesshandlers](start!(.getInputStreamprocess)(.getOutputStreamprocess)handlers))([^InputStreamin^OutputStreamouthandlers](let[client-in(ArrayBlockingQueue.16)server-in(ArrayBlockingQueue.16)server-out(ArrayBlockingQueue.16)](lsp-jsonrpcclient-inserver-inserver-outhandlers)(lsp-baseinserver-inoutserver-out)client-in)))
我为 start!
函数创建了 2 个 arities:
- 专用于进程 stdio 的 Helper process arity,因为这是 99% 的 LSP client/server 通信实现中使用的方式。我们将使用它来启动服务器。
- 基于 InputStream+OutputStream 对的通用 arity。这个 arity 是完成工作的 arity。LSP 允许各种传输方式,例如管道、网络套接字或进程之间的 stdio 通信。通用 arity 支持所有这些,你只需要提供输入和输出流。在设置中,我分配了小的缓冲区,因此如果 commucation 的某些部分消耗得太慢(或产生得太快),则会有一些缓冲和背压。老实说,我不知道这些缓冲区大小是否好,我只是编造了它们。无论如何,在这里,我们调用
lsp-jsonrpc
和lsp-base
来将所有内容连接在一起,最后返回client-in
。是的,LSP 客户端对象只是一个队列。是的,在适当的实现中,它可能应该是其他的东西,例如自定义类型。
下一步是发送一个 notification。这比发送一个请求更简单,因为我们没有收到响应:
(defnnotify!([^BlockingQueuelspmethod](.putlsp{:methodmethod}))([^BlockingQueuelspmethodparams](.putlsp{:methodmethod:paramsparams})))
最后,发送一个请求。如果你还记得,当我们实现 lsp-jsonrpc
函数时,我们同意 LSP request maps 将使用一个带有队列值的 :response
键。现在是时候这样做了:
(defnrequest!([lspmethod](request!lspmethodnil))([^BlockingQueuelsp-clientmethodparams](let[queue(SynchronousQueue.)](.putlsp-client(cond->{:methodmethod:responsequeue}params(assoc:paramsparams)))(let[m(.takequeue)](if-let[e(:errorm)](throw(ex-info(:messagee)e))(:resultm))))))
SynchronousQueue
是一个缓冲区大小为 0 的队列。这意味着每个阻塞的 .take
(我们在这里执行)将等待直到其他人(lsp-jsonrpc
函数)将值放入队列中。所以这就像我们在这里等待的承诺。此实现创建一个请求映射,将其提交给 lsp 客户端,然后阻塞直到收到来自语言服务器的响应。额外的好处是 JSON-RPC 错误作为 java 异常抛出,并且成功的结果简单地作为值返回。好像这是一种同步的 "method call"。由于 virtual threads,这也表现良好。Java 24 真的很好。
无论如何,就这样!我们现在可以启动语言服务器并使用它们做一些事情!耶,我们实现了一个 LSP 客户端,全部在 150 (甚至不是 200) 行代码中!
耶?
你现在可能会感到有点失望,因为我们所做的一切——基本层和 jsonrpc 层——虽然 LSP 需要,但实际上与实际的语言服务器没有任何关系。但它非常简洁、简短和集中!哦,好吧。现在,我想,是时候通过实际尝试使用语言服务器来摧毁所有这些美了。毕竟,我们仍然有 50 多个 LoC 的预算。
丑陋的 linter
让我们首先讨论语言服务器的生命周期。当客户端启动语言服务器时,它实际上并没有立即准备好使用。现在我们进入了真正的 LSP 集成领域。我们必须初始化它(一个请求),然后通知它它已初始化(一个 notification),然后使用它(发出 0 个或多个请求或通知),然后关闭它(一个请求),然后最终通知它以便它可以退出(另一个 notification)。初始化过程是交换 capabilities 所必需的:客户端说它可以做什么,然后服务器说它可以做什么,并且 LSP 要求客户端和服务器都遵守他们彼此说过的内容。例如,一个适当的语言客户端(如文本编辑器,而不是我们在此构建的玩具)可能会说 "我会问你关于代码完成的问题,但请不要通知我关于你的 linting,因为我不支持显示波浪线",并且服务器可能会说 "我可以同时提供代码完成并通知你关于代码问题,因为你输入,但我不会这样做,因为你不支持它"。 所有 capabilities 都定义在 LSP 规范中,并且几乎所有这些 capabilities 都是可选的。这允许 LSP 客户端和服务器开发人员随着时间的推移逐步构建支持。例如,在 Defold editor 中,LSP 支持的故事仅从显示 diagnostics(这是 LSP 规范用于 linting squigglies 的术语)开始,然后逐渐扩展到代码完成、hovers 和符号重命名。 让我们看看我们为 diagnostics 准备了什么。diagnostic 是描述代码问题的数据。它具有文本范围(类似于 "从第 20 行字符 5 到第 20 行字符 10")、严重性(警告/错误等)和文本消息。LSP 规范定义了我们可以用来从语言服务器获取 diagnostics 的这两个方法:
- document diagnostics:客户端可以请求服务器 lint 一个特定的文件并返回结果。
- workspace diagnostics:客户端可以请求服务器 lint 整个项目并返回结果。
因此,有了这两个方法,以及我们不错的 LSP 客户端实现,我们可以草拟一个 linting 函数,该函数使用大致以下算法执行 linting:
- 启动服务器
- 初始化它,告诉服务器我们可以要求它提供 workspace 和 document diagnostics
- 如果服务器支持 workspace diagnostics,我们使用该方法;如果服务器支持 document diagnostics,我们列出项目中的所有文件并要求它 lint 它们;否则,我们报告一个错误,即服务器无法执行我们希望它执行的操作。
- 我们关闭服务器
应该很容易。真的,应该这么容易!应该很容易!!!为什么不容易?!.. 好的。 丑陋的部分来了。 在准备这篇文章时,我尝试了很多语言服务器作为示例使用。我只需要其中一个实现任何一个方法。但是没有。它们中没有一个这样做。所有这些语言服务器都吹嘘它们提供 diagnostics。它们甚至没有说谎。但是!它们实际上并没有按需实现 diagnostics。你看,语言服务器可以使用第三种方式来提供这些令人讨厌的小 squigglies。它们可以随时随地,作为一个 notification,随便发布它们。无法询问它们。这就是它们所做的。它们都这样做。并且它们主要通过对来自客户端的两个特定 notifications 的响应来执行此操作:当客户端通知服务器它打开了一个文档时,以及当客户端通知服务器打开的文档的文本已更改时。这种 notification 方法首先存在,并且每个语言服务器实现者都使用它,因为它很容易,而且它有效,并且所有其他方法都是不必要的。对于文本编辑器来说,这完全有意义:在大多数情况下,你只对你正在编辑的文件的 squigglies 感兴趣,而你正在编辑它。但不幸的是,这意味着如果不构建一个成熟的文本编辑器,我就无法创建一个使用我们的小型语言客户端来做一些有用的事情的不错的示例——所有其他功能只有在代码编辑上下文中才有意义,我们在其中有光标和文本选择,并且我们可以询问语言服务器关于此行上的内容。 所以。它会很丑陋。但这不是 LSP 规范的问题。只是我不走运,使用了我想使用的例子。代替这种简单直接的请求/响应,我将要做一些可怕的事情。我将启动一个语言服务器。我将初始化它,只说我愿意接收 diagnostic notifications。我将完全忽略服务器 capabilities,因为在这一点上为什么要打扰。然后,我将打开项目中的每个文件,然后我会等待一段时间以接收 diagnostic notifications,然后我会关闭这个令人憎恶的东西。我不打算解释所有的代码,因为它太糟糕了,但是 这里是它所有的荣耀。在这里,我只会展示好的部分。 我们从一个函数签名开始:
(defnlint[&{:keys[cmdpathext]}]...)
该函数接受要运行的 LSP shell cmd
(字符串或字符串的集合)、要 lint 的目录 path
和用于选择要 lint 的文件的文件 ext
扩展名。由于该函数接受 kv-args,并且它位于 github 上,并且你正在使用最新的 clj
工具(不是吗?),你实际上可以尝试运行它。也许它甚至会起作用!例如,你可以下载 clojure-lsp,然后在你的项目中运行以下命令:
clj -Sdeps '{:deps {io.github.vlaaad/lsp-clj-client {:git/sha "57c618d7ecfc9f94fbef9157cfe4534a4816be45"}}}' \
-X io.github.vlaaad.lsp/lint \
:cmd '"/Users/vlaaad/Downloads/clojure-lsp.exe"' \
:path '"."' \
:ext '"clj"'
对于我们在这篇文章中讨论的代码,输出将如下所示:
file:///Users/vlaaad/Projects/lsp-clj-client/src/io/github/vlaaad/lsp.clj at 168:22: Redundant let expression.
事实证明,在 lint
函数实现中存在一个警告!但是警告位于代码的一个糟糕的、混乱的部分中,因此没有必要在该函数中修复它。没有什么可以修复这个函数... 无论如何,我们启动一个进程,然后将其设为一个服务器:
(let[......^Processprocess(applyprocess/start{:err:inherit}(if(string?cmd)[cmd]cmd))......server(start!process{"textDocument/publishDiagnostics"(fn[diagnostics]...)})]...)
我们只会监听 textDocument/publishDiagnostics
notification,当我们打开文件时,语言服务器可能会发送该 notification。在这一点上,服务器尚未初始化,所以我们接下来这样做:
(request!server"initialize"{:processId(.pid(ProcessHandle/current)):rootUri(uripath):capabilities{:textDocument{:publishDiagnostics{}}}})
我们发出一个阻塞 initialize
调用,并告诉服务器我们的进程 id(因此如果我们在停止它之前死亡,它可以退出),哪个目录是项目根目录,以及我们的 capabilities 是什么。你期望获取返回值并检查它是否例如支持 diagnostics,但我决定在此示例中跳过它。
下一步:我们通知服务器它已 initialized
:
(notify!server"initialized")
不确定为什么这是必要的,但协议要求它。然后我们使用服务器并打印结果(省略了恐怖)。然后我们关闭它:
(request!server"shutdown")(notify!server"exit")
就这样!
讨论
好的,让我们深吸一口气。我深吸一口气,花了一些时间反思所有这些。我喜欢 LSP。它对生态系统有好处:IDEs 可以更好地支持更多的编程语言,并且编程语言更容易集成到更多的 IDEs 中。对于构建命令行 linters 来说,它不是一个好的协议:即使协议支持它,但在现实中,很难找到具有必要 capabilities 的服务器。但我保证,它更适合构建文本编辑器 :) 我为 Defold 编辑器构建了 LSP 支持。现在我还花了一些时间反思它,我想分享我对这件事的看法。首先,将 diagnostics 集成到文本编辑器中实际上非常容易,因为没有明确要求 diagnostics,它们只是出现并显示。这不是复杂的部分。Defold LSP 支持比我们的玩具实现复杂得多,因为文本编辑器需要管理整个语言服务器动物园,每个服务器都有自己的生命周期、初始化过程和 capabilities。在文本编辑器中实现 LSP 支持时,我发现大部分复杂性来自于不得不管理这个动物园,其中每个服务器都有不同的运行时状态(启动、运行、停止),并且其中每个语言服务器进程可能随时决定死亡。例如,这使得以下内容复杂化:
- 跟踪具有未保存更改的打开文件。文本编辑器不仅需要在用户打开文件时通知正在运行的语言服务器,还应通知新启动(或重新启动)的服务器有关所有当前打开的文档。需要对打开的(用户可见的)和未保存的(不一定对用户可见的)文件进行簿记。
- 同时向多个服务器发送请求。这可能不是很明显,但是 LSP 不会妨碍同时运行多个语言服务器——对于同一语言,在同一项目中。VSCode 这样做。Defold 编辑器也这样做。当编辑器请求代码完成以呈现完成弹出窗口时,LSP 集成实际上会向目标语言的所有有能力的运行语言服务器请求,然后合并结果。诊断显示也适用。每个文件有多个语言服务器非常有用。例如,你可以运行专用于代码分析的语言服务器,以及一个拼写检查语言服务器,该服务器突出显示错别字,并且编辑器将在同一文件中显示来自两者的 diagnostics。因此,实现支持同时向具有不同 capabilities 的多个语言服务器发送请求,其中每个服务器可能随时死亡,但我们仍然希望在一段时间内收到来自所有匹配服务器的响应,这并不容易。
与此相比,这里是我 以前读过 的对 LSP 的批评,但我发现它没有说服力:
- 缺少因果关系。编辑器更改代码,然后立即向服务器请求代码操作之类的内容。服务器可能没有机会更新其内部状态,并且将返回过时文本状态的结果。或者它将发布不再适用的 diagnostics。但是然后它会在稍后发布正确的 diagnostics。我认为这无关紧要,因为该问题很容易通过例如文本编辑器中的撤消或重复请求来恢复,或者它会在稍后自动恢复。不需要强大的因果关系/一致性保证:与语言服务器的交互主要是只读的,事物有点宽松/迟到是无害的。
- 不同的端点以略有不同的方式对数据进行编码。例如,对文本文件的未保存更改以增量方式(作为差异)进行通信,但是文本文档轮廓(即定义的类/函数/模块等的列表)始终完全刷新。我认为这里的不一致性无关紧要:编写预/后处理很容易。不同的状态同步方法由上下文决定,并且存在权衡。文本状态同步应该很快,因此要求客户端和服务器支持增量文本同步是合理的——我们可能会编辑非常大的文件,我们不应该在每次更改时不断地完全发送它们。另一方面,轮廓刷新是根据需要而不是在键入时请求的,因此那里不需要增量差异。
- 规范很大。它是,但这无关紧要:我们可以使用 capabilities 选择其中的一部分。
- 奇怪的类型定义。许多请求/响应的 JSON 模式都是使用 Typescript 类型编写的。说实话,我最初对此感到困惑,但我很快就习惯了。它能够充分地传达数据的形状。
LSP 有它的缺点和不一致之处,就像每个随着时间的推移而发展起来的成功的协议一样。如果现在从头开始设计,它会更简单,特别是围绕请求和响应数据形状。但这不如管理服务器的状态那么困难,这是语言服务器是单独的有状态进程的不幸结果。也许,LSP 的继任者将不是更好的进程间通信协议,而是 WASM "接口",它将允许以进程内,同步的方式,使用任何语言编写语言服务器,只要它可以编译为 WASM 即可。然后,每个代码编辑器都将运行一些 WASM 运行时。同时,LSP 比构建定制的语言集成要好得多,所以我很高兴使用它。
(cons "vlaaad[](https://vlaaad.github.io/<https:/github.com/vlaaad/>) ©" (iterate inc 2019))