---
title: LSP 与语言服务器
url: https://doc.liz6.com/compilers/08-compilation-technology-applications/04-lsp-and-language-servers
locale: zh
area: compilers
tags:
- compilers
- 编译技术应用
date: 2026-06-30
modified: 2026-07-16
description: IDE 的补全、跳转、悬停类型、重构——这些代码智能全部来自 language server,而 language server 本质上就是编译器前端+增量更新+容错解析。LSP 把词法/语法/符号表的能力以 JSON-RPC 暴露给编辑器。
---

# 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` | 语义分析(类型检查) | 这个文件有哪些类型错误/未使用变量? |

**所有能力来源于 [符号表](/compilers/03-semantic-analysis/01-symbol-tables-and-scopes.md)**。`definition` 是符号表的一次 lookup,`references` 是符号的 use 列表,`completion` 是符号表在当前作用域的列举,`hover` 是符号的类型和 doc string。如果符号表不保留"这个名字在哪被引用了"的反查索引,`references` 和 `rename` 就没法高效——构建符号表时要用双向的(从定义到引用、从引用到定义)。

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

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

<svg viewBox="0 0 720 380" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="持久化索引的三步生命周期与索引内容">
  <rect width="720" height="380" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">持久化索引:启动加载、增量更新、按需查询</text>
  <defs><marker id="idxah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>

  <rect x="10" y="90" width="60" height="30" rx="15" fill="#e2e8f0"/>
  <text x="40" y="109" text-anchor="middle" font-size="11" fill="#475569">启动</text>
  <line x1="70" y1="105" x2="88" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#idxah)"/>

  <rect x="90" y="68" width="170" height="74" rx="8" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="175" y="90" text-anchor="middle" font-size="13" font-weight="700" fill="#3730a3">① 加载已有索引</text>
  <text x="175" y="108" text-anchor="middle" font-size="11" fill="#3730a3">从上次存储的结果</text>
  <text x="175" y="124" text-anchor="middle" font-size="11" fill="#3730a3">反序列化载入</text>
  <line x1="260" y1="105" x2="288" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#idxah)"/>

  <rect x="288" y="68" width="170" height="74" rx="8" fill="#e0e7ff" stroke="#c7d2fe"/>
  <text x="373" y="90" text-anchor="middle" font-size="13" font-weight="700" fill="#3730a3">② 增量更新</text>
  <text x="373" y="108" text-anchor="middle" font-size="11" fill="#3730a3">对自上次关闭后</text>
  <text x="373" y="124" text-anchor="middle" font-size="11" fill="#3730a3">有变化的文件重新解析</text>
  <line x1="458" y1="105" x2="486" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#idxah)"/>

  <rect x="486" y="68" width="170" height="74" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="571" y="90" text-anchor="middle" font-size="13" font-weight="700" fill="#115e59">③ 查询响应</text>
  <text x="571" y="108" text-anchor="middle" font-size="11" fill="#115e59">打开新文件:查外部符号</text>
  <text x="571" y="124" text-anchor="middle" font-size="11" fill="#115e59">只在内存解析该文件</text>

  <line x1="60" y1="160" x2="660" y2="160" stroke="#e2e8f0" stroke-width="1"/>
  <text x="60" y="186" font-size="13" font-weight="700" fill="#1f2933">索引存什么</text>

  <rect x="60" y="200" width="92" height="24" rx="5" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="106" y="216" text-anchor="middle" font-size="11" font-weight="700" fill="#3730a3">导出符号</text>
  <text x="166" y="217" font-size="12" fill="#475569">每个文件导出 (pub) 的符号:名字、位置、类型签名、文档</text>

  <rect x="60" y="234" width="92" height="24" rx="5" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="106" y="250" text-anchor="middle" font-size="11" font-weight="700" fill="#3730a3">导入列表</text>
  <text x="166" y="251" font-size="12" fill="#475569">每个文件的导入 (use/import) 列表,快速查"谁引用了该符号"</text>

  <rect x="60" y="268" width="92" height="24" rx="5" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="106" y="284" text-anchor="middle" font-size="11" font-weight="700" fill="#3730a3">依赖图</text>
  <text x="166" y="285" font-size="12" fill="#475569">符号依赖图:A 调用 B → A 所在文件依赖 B 的定义文件</text>

  <rect x="60" y="306" width="600" height="56" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="76" y="326" font-size="12.5" fill="#115e59">索引让 language server 不必打开文件时现解析全项目——只对有变化的文件增量更新,</text>
  <text x="76" y="348" font-size="12.5" fill="#115e59">不受影响的文件直接查表命中,这是 &lt;100ms 响应的关键。</text>
</svg>

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

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

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

这要求 [AST 设计与错误恢复](/compilers/02-syntax-analysis/03-ast-design-and-error-recovery.md) 里的错误恢复做到:看到一个不完整的表达式,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)的典型架构:

<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="language server 单进程多线程调度架构">
  <rect width="720" height="300" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Language Server 架构:单进程、多线程、增量调度</text>
  <defs><marker id="archah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>

  <rect x="40" y="50" width="190" height="80" rx="8" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="135" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="#3730a3">主线程 (IO)</text>
  <text x="135" y="94" text-anchor="middle" font-size="11" fill="#3730a3">接收 JSON-RPC 请求</text>
  <text x="135" y="112" text-anchor="middle" font-size="11" fill="#3730a3">→ 发到调度器</text>
  <line x1="230" y1="90" x2="258" y2="90" stroke="#475569" stroke-width="1.6" marker-end="url(#archah)"/>

  <rect x="260" y="50" width="190" height="80" rx="8" fill="#e0e7ff" stroke="#c7d2fe"/>
  <text x="355" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="#3730a3">调度器</text>
  <text x="355" y="94" text-anchor="middle" font-size="11" fill="#3730a3">请求队列,同文件合并(去抖)</text>
  <text x="355" y="112" text-anchor="middle" font-size="11" fill="#3730a3">→ 分配工作线程</text>
  <line x1="450" y1="90" x2="478" y2="90" stroke="#475569" stroke-width="1.6" marker-end="url(#archah)"/>

  <rect x="480" y="50" width="190" height="80" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="575" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="#115e59">工作线程</text>
  <text x="575" y="94" text-anchor="middle" font-size="11" fill="#115e59">解析文件 → 更新索引</text>
  <text x="575" y="112" text-anchor="middle" font-size="11" fill="#115e59">→ 产生响应</text>

  <rect x="40" y="150" width="310" height="60" rx="8" fill="#ffedd5" stroke="#f97316"/>
  <text x="56" y="172" font-size="12" font-weight="700" fill="#9a3412">去抖 (debounce)</text>
  <text x="56" y="190" font-size="11" fill="#c2410c">用户 500ms 内连续输入 10 字符,</text>
  <text x="56" y="204" font-size="11" fill="#c2410c">不逐字重解析——等 50ms 无输入后再解析</text>

  <rect x="370" y="150" width="310" height="60" rx="8" fill="#ffedd5" stroke="#f97316"/>
  <text x="386" y="172" font-size="12" font-weight="700" fill="#9a3412">取消 (cancellation)</text>
  <text x="386" y="190" font-size="11" fill="#c2410c">上一轮解析未完成、新输入已到,</text>
  <text x="386" y="204" font-size="11" fill="#c2410c">用 $/cancelRequest 丢弃上一轮工作</text>

  <rect x="40" y="224" width="640" height="56" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="56" y="246" font-size="12.5" fill="#115e59">去抖把"连续输入"合并成"最后一次才解析",取消丢弃已过时的中间结果——</text>
  <text x="56" y="266" font-size="12.5" fill="#115e59">两者是响应能稳定 &lt;100ms 的关键,不是全量重算,而是只做必要的那一次。</text>
</svg>

去抖和取消是性能的生命线——用户在快速打字时,`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*
