本页目录
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。如果符号表不保留"这个名字在哪被引用了"的反查索引,references 和 rename 就没法高效——构建符号表时要用双向的(从定义到引用、从引用到定义)。
索引:进入文件前就知道每个文件导出什么
大项目几千个文件,用户刚打开 main.rs 时 language server 不可能解析全项目。它需要一个持久化索引:
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),但正则不知道语义——foo 在 let 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)的典型架构:
去抖和取消是性能的生命线——用户在快速打字时,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