LSP 与语言服务器

本页目录

Language Server Protocol 把补全、跳转、诊断等语言能力定义为编辑器与语言服务之间的协议。它减少不同编辑器分别适配每种语言的重复工作,但不要求语言服务必须采用某种编译器、线程模型或数据库。

语言服务器可以复用编译器前端,也可以维护专门面向交互的分析引擎。它面对的是持续变化、经常不完整的源码,需要在响应速度、结果完整度和版本正确性之间组织工作。IDE 的语言功能也可以通过编辑器内部实现,LSP 并不是唯一来源。

协议先约定能力,再交换请求与通知

LSP 使用 JSON-RPC 消息模型。请求有 ID,并对应结果或错误;通知没有对应响应。初始化阶段双方交换能力,再按协商结果使用功能。例如 textDocument/publishDiagnostics 是服务器发出的通知,不能把它列为编辑器发起的普通查询。

文档同步通常包括 didOpen、didChange、didClose 等通知,具体使用整份文本还是增量编辑由能力约定。对于已打开文档,服务器要分析编辑器同步的未保存内容,而不能每次重新读取磁盘上的旧文件。LSP 3.17 规范

一次补全:围绕文档版本构造可取消的查询 编辑器同步文档变化 didChange:更新到版本 42 1 服务器建立分析快照 语法、类型与项目配置使用一致视图 2 编辑器请求光标处补全 从上下文、可见符号与类型生成候选 3 新的编辑到达版本 43 旧任务可取消,旧结果需检查是否仍适用 4 返回与当前上下文匹配的结果 编辑位置与文本编码必须解释一致 5 文档版本是同步与结果管理依据;并非每种 LSP 请求都直接携带版本字段。

从一次字段补全理解分析依赖

用户输入 account.,此时表达式还不完整。分析引擎可以保留接收者 account、点号和缺失成员节点,继续解析 account 的绑定与类型,再枚举候选成员。候选还可能受可见性、泛型替换、trait、扩展方法和预期类型约束。

因此补全不只是“把符号表所有名字列出来”。语法位置决定期望哪类候选,名字解析确定具体声明,类型分析决定哪些成员可用,排序与自动导入则改善交互体验。

编译器同样可以进行错误恢复,而不是遇到第一个不完整表达式就必须停止。区别主要在目标:交互系统更频繁地要求带错源码仍能提供局部有用结果。

常见能力速查

消息或请求核心工作不能忽略的边界
completion上下文、可见符号、成员与排序不完整语法、自动导入和替换范围
definition从使用位置解析具体定义重载、宏展开与生成代码位置
references以符号身份查询引用同名不同符号、跨项目与索引完整度
hover类型、文档与相关信息类型实例化、错误恢复状态
signatureHelp当前调用的候选签名与参数位置嵌套调用和重载
rename生成关联位置的 WorkspaceEdit名字捕获、冲突、文件版本和可写性
publishDiagnostics服务器推送诊断旧版本结果的淘汰;另有拉取诊断接口
semanticTokens返回分类后的文本范围位置编码、能力与 legend

Rename 不是全局字符串替换。两个作用域里的同名变量不是同一符号;宏展开中的名字也未必有可直接修改的源位置。服务器生成编辑集合后,客户端仍需按协议与版本状态应用。

增量计算:缓存的是查询依赖,不只是文件

设查询链为“文件文本→语法树→声明签名→函数体类型→诊断”。修改函数体内一个局部表达式,可能使该函数的类型结果失效,却不影响导出签名;修改公共类型则可能影响多个依赖文件。

增量引擎记录依赖关系与输入版本,只重算受影响查询,并可在结果未变化时阻止失效继续传播。“只重算被改的文件”过窄,“一改就重算全项目”又过宽。项目配置、编译选项、依赖版本和生成文件也都是输入。

rust-analyzer 的架构将语法、语义和增量查询等职责分开,体现了这种面向交互分析的组织方式;这不意味着所有结果都必须持久化成磁盘索引。rust-analyzer 架构

项目索引:用符号身份连接声明与使用

项目级索引帮助查找未打开文件里的符号、引用和关系。它可以结合当前打开文件的动态结果、后台建立的索引,以及可选的外部索引。缓存或磁盘持久化是性能手段,不是 LSP 的强制要求。

clangd 将不同来源的索引组合起来,兼顾当前编辑状态与整个项目的信息。clangd 索引设计

索引必须按实际编译上下文解释源码。对 C/C++,同一文件在不同宏定义或 include 路径下可能代表不同程序;仅以文件修改时间作为缓存键可能复用错误结果。索引尚未完成时,也应明确哪些查询可以返回局部结果,哪些操作需要等待更多信息。

位置信息:字符不总等于字节

LSP 位置通常由零起始的行号与行内偏移表示。3.17 支持协商位置编码,未协商其他编码时保留 UTF-16 的兼容默认。UTF-8 字节、Unicode 码点与 UTF-16 code unit 数量可能不同。

例如一个非 BMP 字符在 UTF-16 中占两个 code unit,在 UTF-8 中可能占四字节。若服务器直接把字节偏移当作 LSP character,中文与 emoji 附近的补全替换或诊断范围会偏移。编辑应用顺序、换行表示以及 range 端点也应遵守协议。具体数据结构见 LSP 元模型。

连续编辑:第二个 range 属于第一次编辑后的文本

设第 7 版只有一行 a😀b,位置编码为 UTF-16。a 占 1 个单元,😀 占 2 个,b 的起点是 3;如果内部字符串按 Unicode 码点索引,b 的下标却是 2。先在偏移 1 插入 X,文本成为 aX😀b,b 的 UTF-16 起点变为 4。接着用范围 [4,5) 把 b 替换成 B,才得到 aX😀B。

这两个 change 若放在同一条 didChange 中,也须按数组顺序应用:第二个范围基于中间文本,而不是最初第 7 版。消息里的版本号标记全部变化应用后的版本。文档同步定义

下面是只处理单行、合法 Unicode 文本的转换模型。超过行长的偏移按协议夹到行尾;落在 emoji 代理对中间的偏移在本模型中拒绝,以避免切开字符。这项拒绝策略不是声称协议给所有服务器规定了同一种修复方式。

def index16(text, offset):
    if offset < 0:
        raise ValueError("negative position")
    units = 0
    for index, char in enumerate(text):
        if units == offset:
            return index
        units += len(char.encode("utf-16-le")) // 2
        if units > offset:
            raise ValueError("inside surrogate pair")
    return len(text)  # 行尾或越过行尾


def apply(text, changes):
    for start, end, replacement in changes:
        if start > end:
            raise ValueError("reversed range")
        lo, hi = index16(text, start), index16(text, end)
        text = text[:lo] + replacement + text[hi:]
    return text


assert index16("a😀b", 3) == 2
assert index16("a😀b", 99) == 3
assert apply("a😀b", [(1, 1, "X"), (4, 5, "B")]) == "aX😀B"
assert apply("a😀b", [(1, 3, "")]) == "ab"
for changes in [[(2, 3, "")], [(3, 1, "")], [(-1, 0, "")]]:
    try:
        apply("a😀b", changes)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid edit accepted")

完整实现还需要行索引、CRLF、跨行替换以及整篇替换事件,不能把这里的单行函数直接用于真实文档。每次编辑后应同步更新文本与行索引;缓存旧行起点后只改文本,会让后续所有位置转换建立在错误基础上。Position 定义

同一个光标,为什么会有三个不同偏移?

三个计数器指向的是同一个字符边界,不是三个不同光标。移动边界时,emoji 让 UTF-8 增加四、UTF-16 增加二、码点增加一。随后比较连续编辑的中间文本:插入 X 已经移动 b,下一次替换必须使用新的 UTF-16 范围。下面的按钮选择完整编辑快照,边界滑杆只负责观察,不会暗中修改文本。

正在呈现知识画面
同一个光标,为什么会有三个不同偏移?

UTF-8字节、UTF-16单元与码点不是同一单位。连续 change 按顺序基于中间文本应用;第二次仍用旧范围会指向错误位置或字符内部。

语义高亮与语法高亮

正则规则可以识别局部词法模式;Tree-sitter 的 highlights.scm 主要查询语法树节点,不是把整个文件当作纯正则匹配。它能利用语法结构,但通常不承担完整的跨文件类型解析。Tree-sitter 高亮文档

LSP semantic tokens 允许服务器返回更丰富的分类与修饰符,例如某个标识符是类型、参数还是只读变量。它不是规定“必须为源码每个 token 标注”,也不禁止编辑器同时保留语法高亮作为快速基础层。

调度:快速响应与正确版本同样重要

补全、悬停等交互查询通常应优先于全项目索引。后台分析可以合并相近编辑、复用快照,并在输入变化后取消过时任务;但不能让旧诊断晚到后覆盖新结果。

$/cancelRequest 表达对某个协议请求的取消,不是任意内部后台任务的统一停止机制。取消可能与完成竞争,服务端仍要妥善结束请求;内部分析也需要自己的协作式取消与资源管理。

去抖等待和查询延迟有取舍。某些快速局部分析可以每次编辑都执行;昂贵后台诊断才适合合并。不存在“每次按键解析一定卡顿”或“统一去抖 50ms 就能保证所有请求低于 100ms”的结论。

同一请求的身份和它分析的文档版本是两件事 请求 81 / 快照 v7 后台分析使用固定文本 编辑到 v8 当前状态向前推进 请求 82 / 快照 v8 新结果先完成 请求 81 稍后完成:仍属于 v7 结束协议请求;展示或应用前检查适用性 取消用于减少无用工作;版本与依赖检查负责阻止陈旧结果覆盖当前状态。

为什么取消后仍要做结果检查

假设请求 81 分析 v7,随后文档变成 v8,请求 82 先返回。此时即使客户端发出了取消 81 的通知,81 也可能已经完成计算或正在发送响应。取消不是“这个 ID 从此不会再收到任何消息”的承诺;请求仍须按协议有结束响应。取消机制

可以给内部任务保存 (URI, 文档版本, 项目配置代次)。下面演示一种保守策略:只有完整快照键仍相同才接受结果。LSP 补全请求并不直接携带这里的全部字段,它们由客户端/服务端自己的调度层关联;项目配置变化也可能让文本未变的结果失效。

current = ("file:///demo.lang", 8, 3)
pending = {81: ("file:///demo.lang", 7, 3), 82: current}
shown = []


def finish(request_id, result):
    snapshot = pending.pop(request_id)  # 先结束这一项请求管理
    if snapshot == current:
        shown.append(result)


finish(82, "new completion")
finish(81, "old completion")
assert shown == ["new completion"] and not pending
pending[83] = current
current = ("file:///demo.lang", 8, 4)  # 文本不变,编译配置改变
finish(83, "stale configuration")
assert shown == ["new completion"]

这不是要求任何编辑都丢弃所有工作。更精细的系统可以证明查询依赖未变,或把旧位置映射到新版本后复用结果;但仅凭“请求 ID 较大”不能证明结果有效,因为 ID 用于对应请求与响应,并不是文档版本排序字段。

结果类型过期造成的实际问题应关注的状态
补全替换范围指向旧文本,覆盖新输入请求上下文、光标与文本版本
诊断已修复的错误重新出现诊断所属版本;无版本时的调度策略
重命名编辑对变更后的文件修改错误位置每个文档的版本、冲突与编辑能力
后台索引同名符号连到另一套条件编译定义项目配置、依赖与索引代次

对于重命名,不能把“服务器已算完所有引用”当作文件仍然保持原样。支持时可用带版本的 TextDocumentEdit 表达编辑依据,客户端按版本和能力处理;跨文件编辑还要考虑失败处理能力,不能默认所有客户端都提供完全相同的原子提交语义。

请求号更大,结果就一定更新吗?

这里故意让第二个任务的请求 ID 更小,以免把数值顺序误当作版本顺序。任务保存启动时的文本版本和配置代次,交付时与当前快照比较;过期结果不能覆盖已经接受的新结果。取消只表达意图,图中的迟到交付模拟响应已完成的竞争。修改配置也会使结果失效,即使文本版本未变。

正在呈现知识画面
请求号更大,结果就一定更新吗?

请求 ID 只配对请求与响应。文本版本和项目配置共同决定结果是否适用;取消可能与完成竞争,迟到结果仍必须检查依据。

最小实现的学习顺序

先实现初始化、文档同步与位置转换,确保服务器持有正确文本;再提供局部语法诊断和简单 hover;接着引入名字解析、项目上下文与增量查询;最后实现跨文件引用、重命名和更复杂的补全。每一步都应检查错误源码与连续编辑,而不只检查一次性完整文件。

相关基础:AST 与错误恢复、符号表与作用域。

参考

Keywords: LSP, language server, JSON-RPC, incremental analysis, document version, UTF-16, semantic tokens, cancellation