「语言服务器与 IDE 工具链」

编辑器不该为每种语言写一套补全逻辑,LSP 用一套 JSON-RPC 协议把语言智能交给独立的语言服务器。本文讲解能力协商与消息模型、增量解析与文档同步、符号索引、跳转定义与补全重命名、诊断与格式化,以及性能与缓存策略。

1. LSP 与能力协商

一句话总结: LSP 用 JSON-RPC 把编辑器与语言智能解耦,编辑器发通知与请求,服务器按协商好的能力返回结果。

在 LSP 出现之前,每种语言、每个编辑器都要各写一套补全、跳转、悬停的实现:N 种语言 × M 个编辑器 = N×M 份工作。语言服务器协议(Language Server Protocol,LSP)把这个矩阵拆成 N + M:语言实现方提供一个语言服务器进程,编辑器实现一个通用客户端,两者用基于 JSON-RPC 2.0 的协议通信。VS Code、Vim/Neovim、Emacs、Sublime、JetBrains 都能作为客户端,而 rust-analyzer、clangd、gopls、pyright 只需各写一份。

// 客户端 -> 服务器: 初始化, 声明自己支持的能力
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "processId": 12345,
    "rootUri": "file:///home/dev/project",
    "capabilities": {
      "textDocument": {
        "completion": { "completionItem": { "snippetSupport": true } },
        "hover": { "contentFormat": ["markdown", "plaintext"] },
        "publishDiagnostics": { "relatedInformation": true }
      }
    }
  }
}
// 服务器 -> 客户端: 应答, 声明自己实现了哪些能力
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "capabilities": {
      "textDocumentSync": { "openClose": true, "change": 2 },
      "completionProvider": { "triggerCharacters": [".", ":"] },
      "definitionProvider": true,
      "renameProvider": { "prepareProvider": true },
      "hoverProvider": true
    },
    "serverInfo": { "name": "example-ls", "version": "0.1.0" }
  }
}

协议把消息分成三类:请求(request,有 id,期待应答)、应答(response,带 id,含 result 或 error)、通知(notification,无 id,不期待应答)。绝大多数交互是「客户端请求、服务器应答」:textDocument/definition 要跳转位置,textDocument/completion 要补全列表。反过来也有一条重要的反向通道——服务器主动通知客户端,最典型的是 textDocument/publishDiagnostics:语言服务器把语法错误、类型错误、lint 警告推送给编辑器,编辑器负责在文件里画波浪线。

消息类型有 id期待应答典型方法
请求(客户端→服务器)是是textDocument/definition
请求(服务器→客户端)是是workspace/configuration
通知(客户端→服务器)否否textDocument/didChange
通知(服务器→客户端)否否textDocument/publishDiagnostics

能力协商是协议设计的精髓:客户端在 initialize 里声明「我能渲染 markdown 悬停、我支持 snippet 补全、我支持增量同步」,服务器据此决定返回什么格式。这样一来,协议可以持续演进——新能力作为可选字段加入,老客户端不声明就不用,不存在版本断裂。这与「发一个版本号然后强行升级」的设计相比,兼容性好得多,也是 LSP 能被广泛采纳的重要原因。

2. 增量解析与文档同步

一句话总结: 用户每次敲键都产生一次文档变更,服务器必须支持增量同步,并用增量解析只重算受影响的部分。

编辑器与服务器之间的文档状态必须严格一致,否则「跳转到第 37 行」会跳到错误位置。协议规定了三种同步模式:全量同步(每次变更发送整份文档)、增量同步(只发送变更范围与新文本)、无同步(服务器自己去读文件)。生产级实现一律用增量同步,因为大文件每次全量发送会带来可观的序列化与网络开销。

// 增量同步: 只报告变化的那一小段
{
  "jsonrpc": "2.0",
  "method": "textDocument/didChange",
  "params": {
    "textDocument": { "uri": "file:///p/src/main.rs", "version": 42 },
    "contentChanges": [
      {
        "range": {
          "start": { "line": 10, "character": 4 },
          "end":   { "line": 10, "character": 9 }
        },
        "text": "count"
      }
    ]
  }
}
def apply_change(text, change):
    """把 LSP 的 range 替换应用到本地文本副本."""
    lines = text.split("\n")
    s, e = change["range"]["start"], change["range"]["end"]
    new_line = lines[s["line"]][:s["character"]] + change["text"] \
             + lines[e["line"]][e["character"]:]
    lines[s["line"]:e["line"] + 1] = new_line.split("\n")
    return "\n".join(lines)

print(apply_change("let x = tol;\n", {"range": {"start": {"line": 0, "character": 8},
                                                "end": {"line": 0, "character": 11}},
                                      "text": "total"}))

注意 version 字段:每次 didChange 必须携带递增的版本号,服务器可以在异步处理时判断「我算出的结果是否基于最新版本」,避免把过期的补全结果推给用户。这是所有交互式语言工具都要处理的竞态问题。

增量解析(incremental parsing)是性能的关键。每次敲键都重新解析整份文件,在几十 KB 的文件上还能接受,在几 MB 的文件上就会卡顿。增量解析器(如 Tree-sitter、Roslyn、rust-analyzer 的语法树)把语法树做成不可变持久化结构,变更时只重建从改动点到根节点的路径,其余子树直接复用。

class Node:
    __slots__ = ("kind", "start", "end", "children")
    def __init__(self, kind, start, end, children=()):
        self.kind, self.start, self.end = kind, start, end
        self.children = list(children)

def reuse_unchanged(node, change_start, change_end):
    """改动范围之外的子树可原样复用, 只重建路径上的节点."""
    if node.end <= change_start or node.start >= change_end:
        return node                      # 完全在改动范围外: 复用
    new_children = [reuse_unchanged(c, change_start, change_end)
                    for c in node.children]
    return Node(node.kind, node.start, node.end, new_children)
同步模式传输量实现复杂度适用
全量同步整份文档低小文件、原型
增量同步变更片段中生产实现的标准选择
无同步无低服务器直接读磁盘

3. 符号索引与作用域解析

一句话总结: 跳转、补全、重命名都建立在符号索引之上,索引把「名字在哪个位置被定义」变成可快速查询的映射。

编辑器里最常用的功能——跳转定义、查找引用、重命名、补全——共享同一个底层能力:知道某个标识符指向哪个定义。这需要两样东西:一张符号索引(symbol index)与一套作用域解析(scope resolution)规则。

class SymbolIndex:
    def __init__(self):
        self.defs = {}          # 名字 -> [定义位置]
        self.refs = {}          # 名字 -> [引用位置]
        self.scopes = []        # 作用域栈: (名字, 定义位置)

    def define(self, name, loc, kind):
        self.defs.setdefault(name, []).append({"loc": loc, "kind": kind})
        self.scopes.append((name, loc))

    def resolve(self, name):
        """就近解析: 从作用域栈顶部向下找第一个匹配."""
        for n, loc in reversed(self.scopes):
            if n == name:
                return loc
        return None

idx = SymbolIndex()
idx.define("total", (3, 8), "variable")
idx.define("total", (9, 4), "parameter")     # 内层遮蔽
print("解析 total ->", idx.resolve("total"))

真实语言的作用域规则远比「就近匹配」复杂:需要处理命名空间、模块导入、类继承、泛型参数、宏展开、运算符重载,还要考虑可见性(private/pub/internal)。因此语言服务器通常直接复用编译器的名字解析与类型检查逻辑,而不是另写一套简化版——rust-analyzer 复用 rustc 的语义模型思路,clangd 直接建在 Clang 之上,gopls 建在 go/types 之上。这也是为什么语言服务器往往由编译器团队维护:IDE 智能与编译器的语义分析是同一件事的两种呈现。

索引粒度构建成本查询速度适用规模
单文件内存索引低最快小项目
项目级内存索引中快中小项目(gopls)
磁盘持久化索引高(首次)快(后续)大型项目(clangd、cquery)
按需惰性索引低(启动)首次慢超大仓库

惰性索引是大型代码库的常见策略:启动时只索引当前打开文件及其依赖,其余部分在用户实际跳转时按需加载。这样做的好处是启动快(用户打开 IDE 立刻可用),代价是首次跳转到未索引区域会有明显延迟。clangd 用后台线程预建索引,配合磁盘缓存,在「启动速度」与「跳转速度」之间取得平衡。

4. 跳转、补全与重命名

一句话总结: 跳转是「位置到定义」的查询,补全是「位置加前缀到候选集」的生成,重命名是「符号到所有引用位置」的批量改写。

跳转定义(go to definition)的请求与应答都很简单:客户端给出文档 URI 与光标位置,服务器返回目标位置(可能是另一个文件)。实现上,服务器先做「位置 → AST 节点」的定位(用行号列号在语法树里二分查找),再沿符号表解析到定义,最后把定义位置转回「行号列号」。

// 请求
{ "jsonrpc": "2.0", "id": 7, "method": "textDocument/definition",
  "params": { "textDocument": { "uri": "file:///p/main.py" },
              "position": { "line": 12, "character": 6 } } }
// 应答
{ "jsonrpc": "2.0", "id": 7,
  "result": { "uri": "file:///p/util.py",
              "range": { "start": { "line": 3, "character": 0 },
                         "end":   { "line": 3, "character": 9 } } } }

补全(completion)要复杂得多。用户敲下 . 之后,服务器需要根据光标前的表达式的类型,枚举该类型的成员;用户敲字母时,还需要按模糊匹配排序。一个可用的补全实现至少要做四件事:解析出光标处的「补全上下文」(是成员访问、是导入路径、还是普通标识符)、收集候选集、按相关性排序、把结果裁剪到合理数量。

def fuzzy_score(prefix, name):
    """极简模糊匹配: 连续前缀给高分, 子序列给低分, 否则不匹配."""
    if not prefix: return 1
    if name.startswith(prefix): return 100 - len(name)
    it = iter(name.lower())
    return 10 if all(c in it for c in prefix.lower()) else 0

def complete_members(type_name, type_table, prefix=""):
    """成员补全: 按类型查成员表, 再做模糊过滤与排序."""
    scored = [(fuzzy_score(prefix, m["name"]), m)
              for m in type_table.get(type_name, [])]
    scored = [t for t in scored if t[0] > 0]
    scored.sort(key=lambda t: -t[0])
    return [m for _, m in scored][:50]      # 裁剪, 避免返回上万条

types = {"User": [{"name": "name"}, {"name": "email"}, {"name": "created_at"}]}
print(complete_members("User", types, "na"))

重命名(rename)是补全与跳转的合体:先解析光标处的符号,找出它的所有引用位置(含跨文件引用),生成一个 WorkspaceEdit,一次性提交给客户端。这里有三个陷阱:遮蔽(内层同名变量不该被改名)、动态引用(反射、字符串拼接出的名字,静态分析看不到)、外部引用(其他仓库或已发布 API 的调用方)。前两者靠精确的作用域解析缓解,第三者则需要用户确认或标记为不安全。

功能协议方法依赖的分析
跳转定义textDocument/definition位置定位 + 符号解析
查找引用textDocument/references全项目索引
补全textDocument/completion类型推断 + 作用域
悬停textDocument/hover类型 + 文档注释
重命名textDocument/rename引用集合 + 遮蔽分析
文档符号textDocument/documentSymbol语法树遍历

5. 诊断与格式化

一句话总结: 诊断是服务器主动推送的问题列表,格式化是客户端请求的全文重写,两者都必须与编辑器状态严格同步。

诊断(diagnostics)走的是服务器→客户端的推送通道。语法错误可以边解析边报,类型错误需要完整的语义分析,而 lint 规则可能需要跨文件分析。因此生产级语言服务器普遍把诊断分成多个层级,逐级上报:解析完成后立刻推送语法诊断,语义分析完成后推送类型诊断,后台全项目分析完成后推送跨文件诊断。

// 服务器推送的诊断对象: 位置 + 严重级别 + 来源 + 消息
{
  "range": { "start": { "line": 12, "character": 6 },
             "end":   { "line": 12, "character": 11 } },
  "severity": 1,
  "source": "example-ls",
  "message": "未定义的变量 total",
  "relatedInformation": []
}

格式化(formatting)与诊断相反:客户端发请求,服务器返回全文替换的编辑。格式化涉及大量排版决策——缩进宽度、行宽限制、括号换行策略、空行保留、注释对齐——因此多数语言服务器不自己实现,而是调用已有的格式化工具(gofmt、rustfmt、black、clang-format)。这也带来一个工程细节:格式化会改变所有位置,服务器必须等待格式化完成后再更新自己的文档副本,否则后续的跳转位置会全部偏移。

诊断层级触发时机覆盖范围延迟
语法诊断解析后立即当前文件毫秒级
类型诊断语义分析后当前文件 + 依赖十毫秒级
跨文件诊断后台全项目分析全项目秒级
Lint 诊断按需或后台可配置不定

诊断的去抖动(debounce)是必备优化:用户连续敲键时不应每次都触发完整分析,而是等输入停顿 200~300 毫秒后再算。同理,服务器还要做取消(cancellation)支持:客户端可以通过 $/cancelRequest 撤销一个已发出但尚未完成的请求,服务器在长时间分析中定期检查取消标志,及时放弃过期任务。这两项机制配合版本号校验,构成了交互式工具链的响应性基础。

6. 性能与缓存

一句话总结: 语言服务器的性能瓶颈在「重复分析」与「全量重算」,用缓存、惰性求值与后台预计算把工作摊薄。

一个中等规模的项目,语言服务器要在用户每次敲键后的几百毫秒内给出补全列表,这意味着它必须几乎不重算。性能优化有三条主线:缓存(把算过的结果存起来)、惰性求值(需要时才算)、增量更新(只重算受影响的部分)。

from functools import lru_cache

@lru_cache(maxsize=4096)
def type_of_symbol(symbol_id, revision):
    """按 (符号, 文档版本) 缓存类型查询, 版本变化即自动失效."""
    return {"kind": "int"}             # 真实实现里是昂贵的类型推断

print(type_of_symbol(1, 42))
print(type_of_symbol.cache_info())     # hits/misses 可观测
class MemoTable:
    """带失效传播的记忆化: 依赖变化时只清理受影响的条目."""
    def __init__(self):
        self.cache, self.deps = {}, {}

    def get(self, key, compute):
        if key not in self.cache:
            self.cache[key] = compute()
        return self.cache[key]

    def invalidate(self, changed):
        for k, deps in list(self.deps.items()):
            if changed & deps:
                self.cache.pop(k, None)
                self.deps.pop(k, None)
优化手段作用典型实现
语法树复用避免整文件重解析Tree-sitter、持久化树
记忆化避免重复的类型查询salsa、rust-analyzer
惰性求值只算被请求的部分按需索引
后台预计算用空闲 CPU 提前算好预建索引线程
结果裁剪限制返回条数补全上限 50~200
请求取消丢弃过期任务$/cancelRequest
去抖动合并高频事件200~300ms 延迟

salsa 框架是这类优化的集大成者:它把编译器的每个查询(type_of、signature_of、resolve_import)建模成带版本号的记忆化函数,依赖关系由框架自动追踪,输入变化时只有真正受影响的查询被重算。rust-analyzer 用 salsa 把「每次敲键重新分析整个 crate」变成「只重算依赖链上的一小段」,响应时间从秒级降到毫秒级。这套思路本质上与构建系统的增量编译一致:把「函数」变成「带缓存的纯查询」,让框架负责失效传播。

7. 从零搭一个最小语言服务器

一句话总结: 一个能用的语言服务器只需要消息循环、文档管理、诊断推送与少数几个请求处理器,其余能力可以逐步补齐。

语言服务器的骨架意外地简单:从标准输入读取 JSON-RPC 消息(带 Content-Length 头)、分发到处理器、把结果写回标准输出。下面的极简实现支持初始化、文档打开与变更、以及悬停与补全两个请求。

import json, sys

DOCS = {}

def read_message(stream):
    length = 0
    while (line := stream.readline()) not in (b"\r\n", b"\n", b""):
        if line.lower().startswith(b"content-length:"):
            length = int(line.split(b":")[1])
    return json.loads(stream.read(length)) if length else None

def write_message(msg):
    body = json.dumps(msg).encode("utf-8")
    sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode())
    sys.stdout.buffer.write(body)
    sys.stdout.buffer.flush()

def handle(msg):
    method = msg.get("method")
    if method == "initialize":
        return {"capabilities": {"hoverProvider": True,
                                 "completionProvider": {"triggerCharacters": ["."]},
                                 "textDocumentSync": 1}}
    if method == "textDocument/didOpen":
        uri = msg["params"]["textDocument"]["uri"]
        DOCS[uri] = msg["params"]["textDocument"]["text"]
        return None
    if method == "textDocument/hover":
        return {"contents": {"kind": "markdown", "value": "**example-ls** 悬停提示"}}
    if method == "textDocument/completion":
        return {"isIncomplete": False,
                "items": [{"label": "total"}, {"label": "count"}]}
    return None

def main():
    while True:
        msg = read_message(sys.stdin.buffer)
        if msg is None:
            break
        result = handle(msg)
        if "id" in msg:
            write_message({"jsonrpc": "2.0", "id": msg["id"], "result": result})
# 推送诊断: 服务器主动通知客户端
def publish_diagnostics(uri, text):
    problems = []
    for i, line in enumerate(text.split("\n")):
        if line.count("(") != line.count(")"):
            problems.append({
                "range": {"start": {"line": i, "character": 0},
                          "end": {"line": i, "character": len(line)}},
                "severity": 1, "source": "example-ls",
                "message": "括号不匹配",
            })
    write_message({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
                   "params": {"uri": uri, "diagnostics": problems}})
组件职责常见实现方式
传输层读写带长度头的 JSON 消息标准输入输出或 socket
分发器按 method 路由到处理器字典映射
文档管理维护打开文档与版本号内存字典 + 版本校验
分析引擎解析、索引、类型推断复用编译器前端
能力实现各请求处理器逐项实现
诊断推送主动上报问题分析完成后触发

实现时最容易忽略的是并发与顺序:JSON-RPC 允许请求乱序应答,但 didOpen/didChange 这类通知必须按序处理;如果分析放到线程池,就要保证「版本号更旧的分析结果不能覆盖更新的结果」。另外,服务器崩溃不能拖垮编辑器,因此客户端通常用独立的子进程启动服务器,并实现重启逻辑。把这两点处理好,一个最小语言服务器就已经具备生产可用性了。

8. 总结

环节要点
LSP 协议JSON-RPC 承载请求/应答/通知,解耦编辑器与语言实现
能力协商初始化时双方声明能力,协议可持续演进
文档同步增量同步 + 版本号,保证状态一致
增量解析持久化语法树只重建变更路径
符号索引定义与引用的映射,惰性或预建
跳转与补全位置定位 + 符号解析 + 类型推断
重命名全项目引用集合 + 遮蔽与动态引用处理
诊断分级推送、去抖动、可取消
格式化返回全文编辑,需重新同步位置
性能缓存、惰性求值、后台预计算、结果裁剪

语言服务器是编译器技术「面向交互」的一次重排:同样是解析、索引、类型推断,但目标从「生成正确代码」变成「在 100 毫秒内给出有用的答案」。这个目标差异带来了全新的工程约束——增量、缓存、取消、去抖动、版本一致性——也带来了一条重要的设计启示:把编译器拆成一组带缓存的纯查询,比把它做成一次性的批处理更容易支撑交互式场景。理解了这条思路,再看 rust-analyzer 的 salsa、gopls 的 snapshot 机制、clangd 的索引设计,都能看出同一个模式。下一篇转向另一个工程方向——交叉编译与多目标后端,看看同一份源码如何面向五花八门的指令集与 ABI 生成代码。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类