第 23 篇让语义分析变成了增量查询:修改一个函数体,只有依赖它的调用方才重新检查。但这些能力只能通过命令行使用。写代码时,每次保存文件再切到终端看诊断,效率太低。

本篇把增量分析接入 Language Server Protocol(LSP)。完成后,编辑器里能看到类型错误的波浪下划线,鼠标悬停显示函数签名,Ctrl-click 跳转到定义处。编译器从后台工具变成了交互式开发环境的一部分。

JSON-RPC 与协议骨架

LSP 在编辑器(客户端)和语言服务器之间定义了一套基于 JSON-RPC 2.0 的消息协议。传输层通常是 stdio:编辑器启动服务器进程,通过 stdin/stdout 交换消息,每条消息前面带一个 Content-Length 头。TCP 同样可用,但 stdio 不需要端口管理,是多数编辑器插件的默认选择。

客户端发送请求,服务器返回结果。也有从服务器主动推送的通知。协议交互的最小流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
客户端                          服务器
│ initialize request ──→ │
│ ←── initialize result │
│ initialized notif ──→ │
│ didOpen notif ──→ │
│ ←── publishDiagnostics │
│ hover request ──→ │
│ ←── hover result │
│ definition request ──→ │
│ ←── definition result │
│ didChange notif ──→ │
│ ←── publishDiagnostics │
│ shutdown request ──→ │
│ ←── shutdown result │
│ exit notif ──→ │

initialize 阶段双方交换能力声明。服务器在响应中声明自己支持哪些功能——诊断推送、悬停查询、跳转定义——客户端则告诉服务器它的位置编码偏好和工作区根目录。这一步完成后,客户端发送 initialized 通知确认握手结束,编辑器才开始发送文档打开和修改事件。如果服务器声明了不支持的功能,客户端不会发送对应请求,也就不会触发未实现的错误。

诊断:波浪线从哪里来

用户打开或修改一份 .spr 文件后,编辑器发送 textDocument/didOpentextDocument/didChange。服务器拿到最新文本,调用第 23 篇的增量分析管线——词法、解析、名称解析、类型检查——收集所有错误,然后通过 textDocument/publishDiagnostics 通知把它们推回编辑器。

每个错误被转换成一个 LSP Diagnostic 对象:

1
2
3
4
5
6
7
8
9
10
{
"range": {
"start": {"line": 1, "character": 14},
"end": {"line": 1, "character": 17}
},
"severity": 1,
"code": "E0308",
"source": "sproutc",
"message": "expected i64, found bool"
}

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 时声明 positionEncodingutf-8utf-32。如果客户端支持 UTF-8 列偏移,可以跳过转换。但服务器不能假设所有客户端都支持——VS Code 默认仍然使用 UTF-16。

悬停:光标下面是什么类型

编辑器发送 textDocument/hover 请求,附带文件 URI 和光标位置。服务器需要:

  1. 把 LSP 位置转换成内部字节偏移。
  2. 在 AST 中找到覆盖该偏移的最小节点。
  3. 如果节点对应一个有类型的表达式或名字,从 Typed HIR 中取出类型信息。
  4. 格式化成 Markdown 返回。

对函数名 sum_to 的悬停,返回内容可能是:

1
2
3
4
5
6
{
"contents": {
"kind": "markdown",
"value": "```sprout\nfn sum_to(n: i64) -> i64\n```"
}
}

对局部变量 x,返回 let x: i64。对字面量 42,返回 i64。找不到有意义的节点时,返回空响应,编辑器不显示悬停框。

AST 节点定位可以用从根向下的递归搜索:检查当前节点的 Span 是否包含目标偏移,包含则向子节点递归,直到找到最深的匹配节点。容错解析树(第 23 篇)保证即使存在语法错误,有效部分的节点仍然携带正确的 Span

跳转定义:从引用到声明

textDocument/definition 请求的处理流程与悬停类似,区别在于最后一步:不是返回类型信息,而是返回定义位置。

  1. 定位光标下的 AST 节点。
  2. 如果是名字引用,取出它在名称解析阶段(第 05 篇)绑定的 DefId
  3. DefId 查到定义节点的 Span
  4. Span 转换成 LSP Location(文件 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
2
3
4
5
6
7
8
9
{
"contributes": {
"languages": [{
"id": "sprout",
"extensions": [".spr"]
}]
},
"main": "./extension.js"
}

extension.jsvscode-languageclient 库启动 sproutc lsp 子进程:

1
2
3
4
5
6
7
const { LanguageClient } = require('vscode-languageclient/node');
const client = new LanguageClient(
'sprout', 'Sprout Language Server',
{ command: 'sproutc', args: ['lsp'] },
{ documentSelector: [{ language: 'sprout' }] }
);
client.start();

打开一份类型错误的 .spr 文件,编辑器在 tru(未定义名字或类型不匹配)下方出现红色波浪线。鼠标悬停在 sum_to 上,弹出签名。按住 Ctrl 点击第 5 行的 sum_to 调用,跳到第 1 行的定义。

修改一个函数签名后保存,调用方的诊断在不到一秒内更新——增量分析只重新检查受影响的函数。连续快速敲击多个字符,最终显示的诊断对应最后一次修改的版本,没有出现闪烁或回退到旧版本的现象。

整个 LSP 实现没有引入独立于编译器的第二套类型规则。悬停显示的类型和诊断报告的错误,都来自第 06 篇以来持续维护的 Typed HIR。增量缓存也是第 23 篇已有的机制。语言服务器本身只负责消息收发、坐标转换和版本管理,不重复实现语义分析。这个分层保证了命令行 check 和编辑器看到的结果始终一致。

练习与资料

  1. 在 Sprout 文件中写一行 // 测试中文注释,下一行写 let x = true + 1;。验证诊断的列号是否指向 true 而非偏移到右边。如果列号偏了,检查 UTF-16 转换逻辑是否正确处理了三字节 UTF-8 字符。
  2. hover 处理中,增加对 if 表达式结果类型的显示。悬停在 if x > 0 { 1 } else { 2 } 上应该返回 i64
  3. --emit=lsp-trace 把所有 JSON-RPC 消息转储到文件,检查 didChangepublishDiagnostics 的版本号是否单调递增。

LSP 3.17 规范定义了所有请求、通知和数据结构的格式。VS Code 语言扩展指南给出了从零搭建扩展和连接服务器的完整步骤。rust-analyzer 的 LSP 实现是工业级参考,其中位置编码转换和取消处理可直接对照。

上一篇:23 - 不完整代码与增量分析。下一篇:25 - 从空目录构建并运行完整应用