本页目录

LSP 与语言服务器

IDE 的补全、跳转、悬停类型、重构——这些代码智能全部来自 language server,而 language server 本质上就是编译器前端+增量更新+容错解析。LSP 把词法/语法/符号表的能力以 JSON-RPC 暴露给编辑器。

概述

IDE 的代码智能——补全、跳转定义、悬停显示类型、重构——传统上每个 IDE 为自己的每种语言写一套分析逻辑(M × N)。LSP(Language Server Protocol)用和 MCP 相同的策略解这个问题:⁠每种语言提供一个 language server 进程,IDE 通过 JSON-RPC 与它通信⁠。对编译技术,LSP 是编译前端(词法/语法/语义/符号表)的直接消费者——language server 本质上就是编译器前端 + 增量更新引擎。这篇讲 LSP 协议的核心请求类型、language server 内部怎么利用前几章的编译器前端技术做索引和响应、以及为什么"够快"比"够精确"更难做到。

LSP 不是编译器,是编译器的前端改了点目标

编译器要的:从源码产出机器码,正确性压倒一切,在一批文件上做批量处理。 Language server 要的:用户每敲一个字符,在 <100ms 内更新补全/诊断/高亮,对不完整/有错的代码也要出合理结果,且只重算改了的部分。

后面一条决定了 language server 不能直接跑整个编译器 pipeline——它要增量⁠,且要容忍语法错误⁠(用户写到一半时源码一定有语法错误)。

核心请求类型:都是符号表 + AST 的查询

LSP 的每一次请求都是对编译器前端数据结构的查询:

请求对应编译器前端查询的东西
textDocument/completion符号表 + 类型系统在当前作用域里,有哪些可见的名字?它们各自的类型和文档?
textDocument/definition符号表这个标识符的定义在哪一行?
textDocument/references符号表 (反查)这个定义被哪些地方引用了?
textDocument/hover符号表 + 类型系统这个标识符的类型是什么?它的文档注释写了什么?
textDocument/signatureHelp类型系统当前函数的参数列表和重载?当前参数是第几个?
textDocument/rename符号表 (全部引用)把这个定义的所有引用处的名字改成新的
textDocument/publishDiagnostics语义分析(类型检查)这个文件有哪些类型错误/未使用变量?

所有能力来源于 符号表definition 是符号表的一次 lookup,references 是符号的 use 列表,completion 是符号表在当前作用域的列举,hover 是符号的类型和 doc string。如果符号表不保留"这个名字在哪被引用了"的反查索引,referencesrename 就没法高效——构建符号表时要用双向的(从定义到引用、从引用到定义)。

索引:进入文件前就知道每个文件导出什么

大项目几千个文件,用户刚打开 main.rs 时 language server 不可能解析全项目。它需要一个持久化索引⁠:

持久化索引:启动加载、增量更新、按需查询 启动 ① 加载已有索引 从上次存储的结果 反序列化载入 ② 增量更新 对自上次关闭后 有变化的文件重新解析 ③ 查询响应 打开新文件:查外部符号 只在内存解析该文件 索引存什么 导出符号 每个文件导出 (pub) 的符号:名字、位置、类型签名、文档 导入列表 每个文件的导入 (use/import) 列表,快速查"谁引用了该符号" 依赖图 符号依赖图:A 调用 B → A 所在文件依赖 B 的定义文件 索引让 language server 不必打开文件时现解析全项目——只对有变化的文件增量更新, 不受影响的文件直接查表命中,这是 <100ms 响应的关键。

rust-analyzer 用 salsa(一个增量计算框架)维护这个索引,任何改动只重新计算受影响的文件,不受影响的文件直接从 cache 出。这是 IDE 响应能保持在 <100ms 的关键——不是全量重算,是增量更新。

容错解析:在有语法错误的输入上出结果

用户打字时(let x = some_struct.)源码必然不全——parser 看到 some_struct. 后面的点,期望一个字段名但看到 EOF。Compiler 到这一步直接报错并停止。Language server 不能停,必须从错误中恢复并给出 . 后面可能是什么字段的补全⁠。

这要求 AST 设计与错误恢复 里的错误恢复做到:看到一个不完整的表达式,parser 仍能构造这个表达式的 AST 节点(标记为 error),上层语义分析能对这棵"带错的 AST"做部分类型推导——从 some_struct 的类型信息中取出它的字段列表,作为补全候选项返回。

用户输入: let x = some_struct.
Parser 产出: Expr::Field { object: "some_struct", field: <Error> }
Semantic Analysis: 从符号表查 some_struct 的类型 → StructFoo { a: i32, b: String }
Completion: [a: i32, b: String]

没有错误恢复就没有补全——这两样在 language server 里是一对⁠。分得开的只有"当前 token 是完整表达式"和"当前 token 是语法错误",后者要求 parser 产出的 AST 节点里标记"field 是缺失的",语义分析据此知道用户在期待这个位置的补全。

Semantic Tokens:语法高亮的另一种实现

传统高亮是用正则匹配(treesitter 的 highlights.scm),但正则不知道语义——foolet foo = ... 里是变量定义,在 foo() 里是函数调用,在 fn foo() 里是函数声明。LSP 的 textDocument/semanticTokens/full每个 token 分配一个语义类别⁠(variable、function、keyword、type、comment...),IDE 根据类别着色。

Language server 在 AST + 符号表上为每个 token 确定语义类别——这要求 lexer 产出的 token 带有足够的信息(是标识符吗?符号表反查它是变量还是函数还是类型?),或由语义分析额外标注。

架构:单进程、多线程、增量调度

工业 language server(rust-analyzer, clangd)的典型架构:

Language Server 架构:单进程、多线程、增量调度 主线程 (IO) 接收 JSON-RPC 请求 → 发到调度器 调度器 请求队列,同文件合并(去抖) → 分配工作线程 工作线程 解析文件 → 更新索引 → 产生响应 去抖 (debounce) 用户 500ms 内连续输入 10 字符, 不逐字重解析——等 50ms 无输入后再解析 取消 (cancellation) 上一轮解析未完成、新输入已到, 用 $/cancelRequest 丢弃上一轮工作 去抖把"连续输入"合并成"最后一次才解析",取消丢弃已过时的中间结果—— 两者是响应能稳定 <100ms 的关键,不是全量重算,而是只做必要的那一次。

去抖和取消是性能的生命线——用户在快速打字时,on_type 频率是 ~100ms 一次字符,如果每个字符都全量解析一次文件,语言服务器 100% 会卡。去抖把"连续 N 次输入"合并为"最后一次之后解析",取消丢弃已过时的中间结果。

参考

  • LSP 3.17 specification: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
  • rust-analyzer: https://github.com/rust-lang/rust-analyzer — Rust LSP server 的架构文档和源码
  • Clangd: https://clangd.llvm.org — 基于 Clang 前端的 C/C++ LSP server

Keywords: LSP, Language Server Protocol, JSON-RPC, completion, go-to-definition, find-references, hover, diagnostics, semantic tokens, incremental parsing, index, debounce, cancellation, error-tolerant parsing, symbol table reverse lookup, rust-analyzer, clangd