这是《LSP 与 VS Code 插件开发》系列文章的第三篇。
第一篇:语言服务器架构
第二篇:语义构建
第三篇:语言服务器协议
第四篇:开发小技巧

现在我们知道,语言服务器是一个独立的进程,它接收文本,输出结构化数据,为代码编辑器提供智能编程服务。那么,这个结构化数据是什么样的呢?它是怎么和代码编辑器通信的呢?这些都由 LSP 规定。

自己动手?

快速开始

介绍协议本身还是太枯燥了。官方有非常好的实践教程,可以先跑一遍。
也可以直接进到教程里的仓库,直接 vs code 启动,断点看看代码是怎么工作的。这个教程好就好在启动成本很低,能非常顺利的搭建一个实例插件,启动插件和语言服务器。

持续深入

跑完这个教程,你就可以在样例工程里改改写写,尝试 LSP 的各种功能了。想知道有哪些能力,查官方文档是必不可少的。

但是语言服务非常重交互,而协议本身数据驱动,只靠文字是很单薄的,难以描述出提供的用户交互体验,初学者只看干巴巴的接口定义肯定会头晕,不知道这些接口都能干什么。不过也不怪 LSP 官方,毕竟只负责设计协议,具体的实现还得靠客户端(各大编辑器)。

这时候就推荐曲线救国了:先去看看 VS Code 内置 API 教程。作为来自客户端编写的教程,文档全面,内容生动。可以很轻量的快速实现小功能,验证想法。
也就是说,在 VS Code 内实现智能编程,有两种方式,一种是通过内置 API,基于编辑器原生能力实现;另一种是通过语言服务器,基于 LSP 通信,数据驱动实现。前者的优势是架构简单,快速验证,不过只能在插件进程工作,只能用 Node.js 编程,并且在复杂场景会有性能问题。(可以参考系列第一章了解语言服务器架构如何解决这些问题)

可以先看语义高亮教程,了解如何通过原生 API 给每个变量上色。

接着,非常推荐这篇文档,它列出了原生 API 和 LSP 的对应关系。毕竟 VS Code 和 LSP 一样都是微软家的,同根同源,深度集成,很多概念和术语都相通。

文档里详细讲解了 VS Code 支持的语言特性,并且都配上了动图和聊胜于无的文字说明。要说为什么是聊胜于无,还是因为交互太重了,各个功能需要多写 demo 去体验,意会。

从学习和方便调试的角度,可以尝试先用 VS Code API 实现一些功能,翻翻 API 的文档,然后再换成 LSP 实现,两者的文档交叉比对,能更好的体会“设计”和“实现”的差异,更好的理解 LSP。

最后,一定要克隆这个仓库,它覆盖了大部分使用原生 API 实现智能编程的例子。当你的代码没有正常工作,一定要来看看示例怎么写的。

介绍协议

术语声明

本文会多次提到“客户端”、“编辑器”、“VS Code”等词,事先澄清,避免混淆:

在 VS Code 这个软件中,编辑器指负责用户交互,能显示、编辑代码的区域,也就是 monaco 编辑器
而客户端指和语言服务器进程通信的进程,也就是语言客户端。在 VS Code 中,运行在 extension host 中的插件进程承担了这个角色,它会启动语言服务器并与它通信,调用 VS Code extension API,数据驱动地改变用户界面,实现智能编程服务。

LSP

再次正式介绍一下,LSP 是指 Language Server Protocol,语言服务器协议。

所谓协议,是规定了两端通信的数据格式、交互方式。
其核心是 LSP 消息。客户端向服务器请求代码补全列表时的消息大概长这样:

1
2
3
4
5
6
7
8
9
10
Content-Length: ...\r\n
\r\n
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
...
}
}

与 HTTP 类似,LSP 消息也分 HeaderBody 两部分。

LSP Header

Content-Length 是 Header 其中一个字段,和 HTTP 一样,代表 Body 的长度。

LSP Body

LSP 使用 JSON-RPC 格式描述消息内容,包括请求和响应。简单来说就是一段 utf-8 编码的 JSON 字符串,好处是简单和平台无关。

通信方式

本质上,语言服务器与客户端通信,是进程间通信。常见的方式有 stdio、ipc、pipe、socket 等。VS Code 插件承担了客户端的角色,在官方的 client SDK 中支持了全部这四种通信方式。具体可以见文档这里

生命周期

本质上,LSP 通信就是两个进程之间的通信。一个进程是语言客户端,对应到 VS Code 里,就是插件的进程(Extension Host),然后由它启动语言服务器进程。
接着,两者会初始化,交换一些信息,主要是两端支持哪些能力。换句话说,不同的编辑器,对 LSP 能力的支持是不同的。我们在语言服务器架构就讲过,BetBrains 仅支持部分功能。例如一个语言服务器提供了全量的语义高亮功能,以及(出于性能原因)按行号范围高亮的功能,后者对于成千上万行的用户文本非常重要,但一些编辑器就是不支持后者的,只支持语言服务器提供整个文件范围的高亮。

这里会有一个坑点:协议规定了客户端如果收到服务器发来的,自己不理解的功能,可以忽略它。这本身没有问题,是为了客户端更健壮,至少此时不应该有 exception,但会增加开发者的调试难度。
我碰到的情况是,客户端仅支持 3.16,而服务器使用了 3.17 新增的 Inlay Hints 能力,两端都能非常顺利的启动、运行,但无论如何编辑器中就是不渲染服务器发过来的 hints。原因就是客户端静默处理了自己不认识的 Inlay Hints 能力,服务器费力编译好算出数据发给客户端,客户端直接丢掉不用了。解决方法就是升级客户端代码,让它支持更新的协议版本。

在这之后初始化已完成,就可以交换数据了。客户端会有几个关键的事件,来推进整个通信流程,比如打开、编辑、关闭、删除、新增、重命名文档等等。
虽然初始化已完成,但服务器还有更多事情要做:通常要先从工程范围编译或者预编译(仅编译一个文件中的签名信息,不编译实现)所有代码文件,记录好基础的语义信息、工程结构等(这通常是在内存中,当然从性能角度也可以在磁盘中加一些缓存)。接着,客户端打开一个文件,语言服务器会返回这个文件的高亮诊断悬浮提示信息等等,这样编辑器里就从白纸黑字升级了。接下来,用户按下键盘输入代码,在编辑过程中服务器会提供代码补全签名提示等服务,用户输入完成后,防抖式的重新编译当前文件,更新高亮、诊断等。
随着用户不断编码,语言服务器不断编译,更新语义模型,持续提供语言服务。这也是语言服务器和编译器工作方式的不同之处。

语言服务器是不是可以仅完整编译打开了的文件,而只预编译其他没打开的文件呢?答案是否定的。
有几个高级功能,依赖工程的完整编译:重命名符号、查找引用,这都依赖到完整编译代码实现。另外,在 A 文件有改动后,引起 B 文件的编译错误,这样是常见的情况,这也需要完整编译。

预编译主要解决了循环引用的问题:在 X 代码块中引用了 Y,在 Y 代码块中由又引用了 X,只要仅编译签名,可以绕过编译代码块,理清依赖关系。这又是语言服务器和编译器工作方式的相似之处。

处理用户输入

处理频繁的、无征兆的用户输入是实现语言服务器的一大难点。在用户按键输入的时候,一方面,用户希望立即得到代码补全提示,其中可能包括当前作用域的变量、函数、类等;另一方面,一行代码也许得等到用户输入到最后一个分号 ; 时才是没有语法错误的。前者需要快速响应,后者需要容错,这往往是矛盾的。

为了性能,语言服务器往往通过防抖的方式,在用户输入完一段时间(例如 200ms)后再编译,更新语义模型。这些智能编程功能中,用户对延迟的敏感度是不一样的。想象一下,你在输入一行代码时,高亮更新慢一些、报错信息晚一点出现,似乎都还可以接受。但如果代码补全的列表迟迟不出现,那就非常痛苦了。没有人想一个一个字母的输入一个冗长的变量名对吧,你想手动打出 This_Is_A_Very_Looong_Variable_Name 吗?更多的时候是希望输入 This 之后一个 Tab 就把剩下的 31 个字母都补全了对吧。这是代码补全功能的最大挑战:如何在容错的前提下快速响应用户输入。后边会在具体的功能中详细说明。

另外,为了加速编译,语言服务器可以实现一个高级特性,就是“增量编译”,只更新用户输入改变了的语义模型。可能的实现方式是,如果用户输入只改了一个代码块里的代码,就只更新这一部分的语义模型,而不是完全重新编译。

具体的请求

好了,接下来会聊聊生动有趣的部分,也是最干的部分:具体的请求和我踩过的坑。

初始化 initialize

这是第一个请求,由客户端发到服务器。服务器可以将自身信息(名字、版本号)还有支持的能力发给客户端。也可以声明每个功能的具体细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const result: InitializeResult = {
capabilities: {
hoverProvider: true, // 悬浮提示
semanticTokensProvider: {
// 语义高亮
full: false,
range: true,
legend: {
tokenTypes: [],
tokenModifiers: [],
},
},
completionProvider: {
// 代码补全
triggerCharacters: [".", '"', "<", "#"],
},
signatureHelpProvider: {
// 签名提示
triggerCharacters: ["(", ","],
},
definitionProvider: true, // 跳转到定义
inlayHintProvider: true, // 内联提示
},
};

这里注意,这个请求是由客户端发向服务端的。服务端声明的,是由客户端发起的请求的支持情况,不包括服务器主动推给客户端的。
例如,这个请求中无需声明 diagnostic 字段,语言服务器就可以主动推送诊断。而客户端需要输入 "(" "," 来触发 signature help。

打开文件 textDocument/didOpen 和改动文件 textDocument/didChange

两个最基础的文件同步请求,客户端发起,服务端接收。
服务端既能接收到文本内容,也能收到文档的版本号。这个版本号是自增的,随着文件改变而提高,因此可以缓存起来,用于判断文件内容有没有变化。

didChange 请求还可以将(一个或多个)具体改动也告知语言服务器,便于语义信息的增量更新等。比如只是删除了一个函数中的一行,也许就不用重新编译作用域内的其他函数。

代码补全 textDocument/completion

随着用户键盘输入,客户端会向语言服务器发送代码补全请求,获取信息后在光标旁渲染一个补全列表。

VS Code 里下拉框的内容是语言服务器给出的数据的超集。这个下拉框里还包括代码片段(snippet),和 VS Code 自带的补全已有输入的内容。

客户端可能会对列表数据重新排序,例如 VS Code,因此语言服务器也许不需要按字典序返回,省一些性能。

代码补全是可以做的非常深的功能。
最简单的,可以补全所有保留字(keywords),这种基本是稳定的,不会被用户代码影响。难点在于随着用户输入,补全项需要动态更新。

矛盾

1
2
3
4
5
6
7
8
9
10
func A() {
var foo = 0
while (true) {
var bar = 1
// 补全 foo、bar,忽略 zee
if true {
var zee = 2
}
}
}

在第 5 行输入时,由于语义要求,应该补全当前作用域以及上层作用域的变量,忽略下层作用域。

这带来一个问题,获取语义信息就意味着需要经过语法分析和语义分析,而你可以想象一下,输入一行代码的过程中,几乎只有最后一个分号写完,这个代码才可能是没有编译错误的。

另外,用户编码时,代码补全如果延迟很高,这会非常痛苦(我想读者们肯定碰到过代码写着写着补全消失的时候),但由于性能原因,语言服务器的更新编译经常是防抖的,也就是在用户停止输入一段时间后再编译,这意味着拿到语义信息有延迟。

这就是代码补全的两个难点:处理容错、以及延迟和性能的矛盾。

这里和具体的语法分析方案相关,我给出一些基于 antlr 做词法分析、语法分析的经验。

首先 antlr 有非常出色的容错能力,即使代码中有语法错误,也可以尽可能解析出正确代码的语法。
这意味着即使正在编辑的这一行代码(和其他位置)有语法错误,其他位置的语义还是可以被正确编译(当然,语言服务器代码中也要有相应的容错,尤其是各种各样的 undefinednil 问题)

但有时候补全往往是和当前这一行相关的,依然需要这一行有错误的代码的语义信息。例如 console. 补全 log warning error 函数。补全项要基于已经输入的代码来异化。

有两个解决的方向,优化语法和从词法尝试推断语法和语义。

优化语法

从语法上,设计更宽容的语法结构,允许一些看上去“没有意义”的代码出现。例如,允许一个属性单独成行,没有读操作也没有写操作。

1
2
3
4
5
6
7
8
9
var x = {
foo: 1,
bar: {
bar1: true,
bar2: "",
},
};

x.bar; //.bar1

大多数情况下,x.bar 单独成行在运行时是没有什么意义的(除非 bar 是一个 getter 函数)。但对于语言服务器,好处是在最后一行输入 x.bar 后,不会有语法错误,再输入点号 .,语言服务器能根据上次的编译结果(即还没有输入点时)得知左侧是一个对象,进而补全 bar1bar1 两个属性。
但也有副作用,就是语法结构会被污染,不仅仅是出于编译来设计,要考虑更复杂的情况。

从词法推断

还有一个艰难的方法,无法借助词法分析工具,就手动分析残缺的词法,推测可能的语法。

1
2
var a = x.
// ^ 光标在此

对于这行代码,必然有语法错误,但 antlr 可以分析出这一行的 token:光标前有一个 DOT,再往前是一个 IDENTIFIER。
就可以尝试分析在当前作用域下,这个 IDENTIFIER 的名字的语义是什么(当然,也有可能是 undefined symbol,那就完全分析失败了),得知是个变量,类型是对象,就一样可以补全其中的属性。

当然,语法结构层出不穷,通过回溯 TOKEN 推测可能的语法结构是一件复杂的事情。

这里推荐这个库 antlr-c3,是专门针对基于 antlr4 的语法分析时,做自动补全的引擎。它会基于语法文件(parser.g4),尝试预测可能的语法节点是什么。

高亮 textDocument/semanticTokens

高亮最好理解了,就是给白底黑字的代码上色。这是一个非常复杂、容易出现性能问题的功能。

VS Code 的高亮

这也是和客户端表现高度相关的功能,先讲讲 VS Code 的高亮系统。一行代码哪里显示蓝色,哪里显示黄色?
首先 VS Code 将文本分段,每一段的渲染方式相同,包括颜色、背景色、字体等,这样的一段被称为一个 token
接着,token 会有类型 type,这一定程度上代表了它的语义,例如关键字、类、枚举等。type 是决定 token 颜色的核心。
VS Code 的颜色主题系统,可以通过 JSON 配置每个 type 的渲染颜色、样式,维护数据表现的映射关系。

1
2
3
4
5
6
7
{
"scope": "keyword", // 关键字
"settings": {
"foreground": "#ff007f",
"fontStyle": "bold"
}
}

这样即使用户切换颜色主题,只需要切换样式即可,比如从亮色模式转为深色模式,但无需重新解析 token

除了 type,还有一个 modifier 的概念,它也会影响 token 的渲染,但只是一种修饰,不是必要的。
例如同样是函数,异步函数、静态函数、被弃用的函数、抽象函数,他们的渲染可以有细微的区别,通过 modifier 来实现,不过这就取决于具体的颜色设计了。

语法高亮

脱离 LSP,VS Code 有一个轻量的无需编程的、基于正则的高亮系统,与语义无关,被称为语法高亮

刚才说到 VS Code 的高亮首先要将文本分段为 token,这其实也是一个词法分析的过程(Tokenization)。方式就是正则表达式,通过正则匹配,基于词法和语法,做简单的高亮,具体的配置规则被称为 tmLanguage 或者 Textmate grammars
比如注释、关键词、操作符、字面量(数字、布尔、字符串等),就很适合由此高亮。

TypeScript 就有一个规模惊人的配置文件。这种文件实在是人类太不可读了,我的建议是要灵活借助 AI 的力量,让 LLM 根据需求生成配置还是比较顺利的。

几个坑点是,tmLanguage.json 采用严格的 json 语法,不能有注释,否则,配置的任何高亮在 VS Code 中都不会生效。想存注释的话,可以手写一个自定义字段来实现。在调试时建议使用官方的检查器,来确认匹配结果。

如果配置文件格式正确,检查器也显示成功匹配(token type 符合预期),但依然没有高亮,那么可以切换颜色主题试试,可能是因为当前颜色主题的色彩太少了,很多 token 就是默认的颜色。

这就低成本实现了简单的高亮。

在2025年的1月版本中(1.97),VS Code 官方开始尝试用 Tree-Sitter 代替 tmLanguage,用词法、语法分析替换单纯的正则匹配。理由是许多 tmLanguage 都不再维护了。

语义高亮

有了 VS Code 的例子,理解 LSP 中的高亮就不困难了。客户端会在某些时机向语言服务器请求高亮数据,服务器返回一个个 token,包括位置、typemodifier 等,客户端自行决定如何渲染。

这是客户端主动发起的请求,在 initialize 请求中,需要一些配置,例如下边这种。一点一点解释:

1
2
3
4
5
6
7
8
9
10
semanticTokensProvider: {
full: {
delta: true,
},
range: true,
legend: {
tokenTypes: ["method","property","string", ...],
tokenModifiers: ["readonly","async","static", ...],
},
}

这个配置有点复杂,如果配的有问题,客户端的高亮会无静默失效,服务器似乎不会收到报错。一行行解释:

所谓的 legend,是用来声明 token 有哪些 typemodifier。他们实际上是两个字符串数组,例如 type 可能是 ["method","property","string", ...]
需要事先声明,是因为字符串用于通信太冗余了,需要做一些压缩:
想象一万行的文件,有多少个 token?如果完整的渲染每一行,浪费不说(用户一次只会看一些行),性能也有巨大的压力。
另外 token 是一个有序的列表,在中间行改动就意味着它后边的所有 token 都得变化,在算法上也有挑战。

为此 LSP 为数据通信做了简单的编码来降低通信的流量。具体的编码方式可以看官方的文档,简单来说就是将位置、typemodifier编码为 5 个整数,通信时不再用明文的 "method" "property" 等。

另外 LSP 还提出两个优化,增量更新(full/delta)和范围渲染(range)。
增量更新是指针对大量 token 时,只在首次请求 textDocument/semanticTokens/full 中返回全量的 token,之后发送 textDocument/semanticTokens/full/delta 请求,语言服务器需要根据文件变化,计算出比起上一次返回的 token 有哪些差异,返回增量部分。
范围渲染就简单了,客户端会根据用户能看到的文本范围,只请求部分范围的 token。请求是 textDocument/semanticTokens/range。因此,语言服务器最好按照文本字符流的顺序收集 token,这样每次请求无需遍历所有的 token,到范围外就可以截断了。

此外,这个请求由客户端发起,这意味着服务端不可控。打开文件、滚动屏幕、调整窗口大小,都会引起这个请求,这个相对好处理。
困难在于用户输入引起的请求。这会引起编译,而高亮需要编译好的语义信息,这就遇到了和签名提示还有代码补全类似的困境:请求时序、防抖更新、等待编译完成等问题。

Vue 的语言服务器使用了简单粗暴,但是非常有效的方式:仅支持范围高亮,并且收到请求后固定等待一段时间,比如 200ms 后再响应。

内联提示 textDocument/inlayHint

这是用来快速浏览信息的功能,通常会用来显示函数定义时形式参数的名字等。否则这个信息需要悬浮提示或者跳转到定义才能拿到,不过也有人会觉得这个提示太干扰,所以最好做成用户可配置关闭的。
这是一个比较新的请求,是 LSP 3.17 新增的。要注意客户端和服务端支持的协议版本都要大于等于 3.17,否则可能会静默失败,没有报错。

签名提示 textDocument/signatureHelp

写函数调用时,输入左括号 ( 时弹出的信息,这就是签名提示。这时候用户会想看到函数的签名信息,包括形式参数的名字、类型,甚至函数返回值等。

时序问题

麻烦在于输入左括号 ( 时还会有一个请求,也就是文件改动 textDocument/didChange,引起防抖处理和重新编译。通常要等编译完成,才能正确的知道具体是针对哪个函数,来获取其签名信息。
很重要的一点是明确 didChangesignatureHelp 的时序问题,因为由用户输入触发的签名提示的相应,依赖编译完成,使用最新的语义信息(旧的状态是没有意义的)。而请求都是由客户端发出的,具体哪个请求会先发出呢?
目前 LSP 协议(3.17)中似乎没有显式的规定这一点,通过咨询官方,结论是用户键入后,客户端应该确保 didChange 先发送到服务器,然后再请求 signatureHelp,也就是说服务器处理 signatureHelp 请求时一定能获取到最新的客户端状态,以及最新的语义信息。

方向键

用户输入后,一定要等防抖以及编译后才能响应,这意味着一定的延迟。但也有一种非常轻量的情况,按方向键也可以触发 signatureHelp,比如光标扫过一个个实际参数时,提示的形式参数的高亮也要改变。

这时候就不用等编译了,因为输入没有改变,只需要根据光标位于哪一个实际参数,高亮形式参数。
不过 didChangesignatureHelp 是两个独立的请求,语言服务器怎么知道一次 signatureHelp 请求是由方向键响应的呢?

还记得前面说 didChange 请求会提供文本的版本号吗,它可以作为输入是否改变的依据。记录每一次 didChange 的版本号,如果两次 signatureHelp 请求之间版本号没有变化(变大),那么输入就没有变化。

代码补全时触发

代码补全时,如果用户选择(resolve)了一个函数,一些语言的服务器会将调括号 () 一起补全,例如 Go。

最好的体验是补全后立即触发 signature help,因为这个时候用户就是想要填实际参数,想知道参数数量和类型。
VS Code 有个指令是 triggerParameterHints,Go 的插件确实是这么做的

Color

如果语法简单,非常适合在前一章提到的语法的(而非语义的)服务器上解析。例如仅支持井号 # 加十六进制这种语法。

但 css 中那种支持 rgba、十六进制甚至直接颜色名称 red 的语法,就不适合了。

诊断 textDocument/publishDiagnostics

这是指把编译错误和警告显示在编辑器里的功能。在 VS Code 里,主要表现为红色、橙色的波浪线。
按理来说,语言服务器的诊断应该和编译器的编译错误的表现一致。当然,除了语言服务器,可能还有别的工具(比如定制化的 lint 等)也在输出诊断,这会导致编辑器里看到的诊断比编译器输出的多。

除了波浪线,诊断还有两种额外的表现 UnnecessaryDeprecated,标记没有引用到的字段,和弃用的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export namespace DiagnosticTag {
/**
* Unused or unnecessary code.
*
* Clients are allowed to render diagnostics with this tag faded out
* instead of having an error squiggle.
*/
export const Unnecessary: 1 = 1;
/**
* Deprecated or obsolete code.
*
* Clients are allowed to rendered diagnostics with this tag strike through.
*/
export const Deprecated: 2 = 2;
}

在 VS Code 里会表现为颜色变浅和删除线。

如果代码里有个字段暂时没法删掉,但又不想有新代码用到,就可以标为废弃,这样同事写出这行代码就会出现删除线,吓他一跳。

诊断还是高亮?

UnnecessaryDeprecated 的表现其实很像是高亮的行为,影响了代码渲染。
而高亮有个修饰符(modifier)也同样是 deprecated

1
2
3
4
5
6
export enum SemanticTokenModifiers {
readonly = "readonly",
static = "static",
deprecated = "deprecated",
//...
}

实际上 VS Code 中很多删除线都是由诊断而不是高亮实现的。
官方说法是,语言服务器不会具体规定客户端的表现,而是由客户端自行决定渲染。对 VS Code 来说,DiagnosticTagSemanticTokenModifiers 出现的更早,因此客户端支持更好。

更多资料

我在播客 Web Worker 上和几位 Vue 生态的大佬、团队成员们聊过 Vue 插件,欢迎收听。

我也会在即刻分享语言服务器相关的开发心得,计划将它们整理成系列文章,欢迎关注。