从零编写现代编译器 24 - 接入语言服务器协议
第 23 篇让语义分析变成了增量查询:修改一个函数体,只有依赖它的调用方才重新检查。但这些能力只能通过命令行使用。写代码时,每次保存文件再切到终端看诊断,效率太低。
本篇把增量分析接入 Language Server Protocol(LSP)。完成后,编辑器里能看到类型错误的波浪下划线,鼠标悬停显示函数签名,Ctrl-click 跳转到定义处。编译器从后台工具变成了交互式开发环境的一部分。
JSON-RPC 与协议骨架
LSP 在编辑器(客户端)和语言服务器之间定义了一套基于 JSON-RPC 2.0 的消息协议。传输层通常是 stdio:编辑器启动服务器进程,通过 stdin/stdout 交换消息,每条消息前面带一个 Content-Length 头。TCP 同样可用,但 stdio 不需要端口管理,是多数编辑器插件的默认选择。
客户端发送请求,服务器返回结果。也有从服务器主动推送的通知。协议交互的最小流程:
1 | |
initialize 阶段双方交换能力声明。服务器在响应中声明自己支持哪些功能——诊断推送、悬停查询、跳转定义——客户端则告诉服务器它的位置编码偏好和工作区根目录。这一步完成后,客户端发送 initialized 通知确认握手结束,编辑器才开始发送文档打开和修改事件。如果服务器声明了不支持的功能,客户端不会发送对应请求,也就不会触发未实现的错误。
诊断:波浪线从哪里来
用户打开或修改一份 .spr 文件后,编辑器发送 textDocument/didOpen 或 textDocument/didChange。服务器拿到最新文本,调用第 23 篇的增量分析管线——词法、解析、名称解析、类型检查——收集所有错误,然后通过 textDocument/publishDiagnostics 通知把它们推回编辑器。
每个错误被转换成一个 LSP Diagnostic 对象:
1 | |
range 标记波浪线的起止位置。severity 为 1 表示错误,2 表示警告。code 沿用第 06 篇分配的诊断码。编辑器收到后,在对应位置画出红色或黄色的下划线,鼠标悬停能看到消息文本。
从编译器内部的 Span(文件 ID + 字节偏移范围)到 LSP 的 Range(行号 + 列偏移),需要一次坐标转换。第 03 篇的源文件映射已经记录了每行起始的字节偏移,从中可以用二分查找定位字节偏移所在的行号,再用该行起始字节偏移计算行内偏移。但 LSP 的列偏移不是字节偏移——这是本篇最容易出错的地方,也是许多语言服务器在处理非 ASCII 源文件时产生位置偏移的根本原因。
UTF-16 列偏移与中文字符
LSP 3.17 规范规定,默认的位置编码使用 UTF-16 code unit 作为列偏移单位。Sprout 源文件是 UTF-8 编码,编译器内部用字节偏移。三种编码下同一个字符占据的宽度不同。
| 字符类型 | UTF-8 字节数 | UTF-16 code unit 数 | Unicode 码点数 |
|---|---|---|---|
ASCII(a, +, {) |
1 | 1 | 1 |
BMP 中文(变, 量) |
3 | 1 | 1 |
补充平面 emoji(𝄞) |
4 | 2 | 1 |
假设第 5 行是 // 变量初始化,注释后紧跟一行代码 let x = 1;。如果编译器不做转换,直接把 UTF-8 字节偏移当成 UTF-16 列号发给编辑器,中文注释后面每多一个汉字,后续行的诊断位置就偏移 2 个字符。表现为波浪线画在错误的标识符上。
转换算法:从行首开始,逐字符遍历 UTF-8 字节流。每遇到一个字符,累加它的 UTF-16 宽度(BMP 字符加 1,补充平面字符加 2),直到字节偏移达到目标位置。累计的 UTF-16 宽度就是正确的 character 值。反方向的转换用于把编辑器发来的位置还原成字节偏移。
LSP 3.17 允许客户端在 initialize 时声明 positionEncoding 为 utf-8 或 utf-32。如果客户端支持 UTF-8 列偏移,可以跳过转换。但服务器不能假设所有客户端都支持——VS Code 默认仍然使用 UTF-16。
悬停:光标下面是什么类型
编辑器发送 textDocument/hover 请求,附带文件 URI 和光标位置。服务器需要:
- 把 LSP 位置转换成内部字节偏移。
- 在 AST 中找到覆盖该偏移的最小节点。
- 如果节点对应一个有类型的表达式或名字,从 Typed HIR 中取出类型信息。
- 格式化成 Markdown 返回。
对函数名 sum_to 的悬停,返回内容可能是:
1 | |
对局部变量 x,返回 let x: i64。对字面量 42,返回 i64。找不到有意义的节点时,返回空响应,编辑器不显示悬停框。
AST 节点定位可以用从根向下的递归搜索:检查当前节点的 Span 是否包含目标偏移,包含则向子节点递归,直到找到最深的匹配节点。容错解析树(第 23 篇)保证即使存在语法错误,有效部分的节点仍然携带正确的 Span。
跳转定义:从引用到声明
textDocument/definition 请求的处理流程与悬停类似,区别在于最后一步:不是返回类型信息,而是返回定义位置。
- 定位光标下的 AST 节点。
- 如果是名字引用,取出它在名称解析阶段(第 05 篇)绑定的
DefId。 - 用
DefId查到定义节点的Span。 - 把
Span转换成 LSPLocation(文件 URI + Range)。
对于跨文件的引用(第 19 篇的模块系统),DefId 自然携带了文件 ID,转换成 URI 后编辑器会打开目标文件并跳到对应位置。
如果光标不在名字引用上——比如在运算符或字面量上——返回空响应,编辑器不执行跳转。对于多文件项目,跳转定义是验证模块边界和导入路径是否正确的最直观手段:如果导入的名字跳不过去,很可能路径写错或者可见性不对。
文档版本与取消
编辑器每次修改文件,didChange 通知都带有一个递增的版本号。服务器收到 didChange 后开始重新分析。但如果用户打字很快,版本 N 的分析还没结束,版本 N+1 就到了。
规则:版本 N 的诊断不能覆盖版本 N+1 的结果。服务器维护一个"最新已收到版本号"。分析完成后,如果发现自己处理的版本已经不是最新,就丢弃结果,不发送 publishDiagnostics。
LSP 定义了 $/cancelRequest 通知。客户端可以主动取消一个还没返回的请求。服务器在分析的各阶段之间检查取消标志——比如词法结束后检查一次,名称解析结束后再检查一次。收到取消后,返回错误码 RequestCancelled(-32800),不返回部分结果。
增量分析让每次重算的工作量变小,取消的需求也相应降低。但对大文件或多文件依赖变更的情况,取消仍然有必要,否则服务器会堆积无用的计算任务,拖慢后续请求的响应。实现上可以用一个原子整数存储最新版本号,分析函数在每个阶段入口读取并比较——如果当前处理的版本已经落后,直接退出当前工作。
编辑器实测
VS Code 通过扩展启动语言服务器。一个最小的扩展只需要在 package.json 中声明语言 ID 和服务器可执行文件路径:
1 | |
extension.js 用 vscode-languageclient 库启动 sproutc lsp 子进程:
1 | |
打开一份类型错误的 .spr 文件,编辑器在 tru(未定义名字或类型不匹配)下方出现红色波浪线。鼠标悬停在 sum_to 上,弹出签名。按住 Ctrl 点击第 5 行的 sum_to 调用,跳到第 1 行的定义。
修改一个函数签名后保存,调用方的诊断在不到一秒内更新——增量分析只重新检查受影响的函数。连续快速敲击多个字符,最终显示的诊断对应最后一次修改的版本,没有出现闪烁或回退到旧版本的现象。
整个 LSP 实现没有引入独立于编译器的第二套类型规则。悬停显示的类型和诊断报告的错误,都来自第 06 篇以来持续维护的 Typed HIR。增量缓存也是第 23 篇已有的机制。语言服务器本身只负责消息收发、坐标转换和版本管理,不重复实现语义分析。这个分层保证了命令行 check 和编辑器看到的结果始终一致。
练习与资料
- 在 Sprout 文件中写一行
// 测试中文注释,下一行写let x = true + 1;。验证诊断的列号是否指向true而非偏移到右边。如果列号偏了,检查 UTF-16 转换逻辑是否正确处理了三字节 UTF-8 字符。 - 在
hover处理中,增加对if表达式结果类型的显示。悬停在if x > 0 { 1 } else { 2 }上应该返回i64。 - 用
--emit=lsp-trace把所有 JSON-RPC 消息转储到文件,检查didChange和publishDiagnostics的版本号是否单调递增。
LSP 3.17 规范定义了所有请求、通知和数据结构的格式。VS Code 语言扩展指南给出了从零搭建扩展和连接服务器的完整步骤。rust-analyzer 的 LSP 实现是工业级参考,其中位置编码转换和取消处理可直接对照。
上一篇:23 - 不完整代码与增量分析。下一篇:25 - 从空目录构建并运行完整应用。

