ARTICLE · 1045208
基于LSP协议的语言插件开发

01
语言插件
1.1 什么是语言插件
现代的代码编辑器(如 VS Code、Sublime Text、Vim、Emacs)在出厂时是“通用”的,预设了一系列能力:它们知道如何编辑文本、管理文件、运行任务,但并不理解任何一门具体的编程语言。当我们在编辑器里打开一个 .c 文件时,编辑器最初看到的只是一段普通文本——不知道 int 是关键字、不知道括号是否匹配、更不知道某个函数名拼写错了。
要让编辑器“理解”一门语言,需要向它注入一系列与语言相关的能力,这些能力的载体就是语言插件(Language Extension)。一份完整的语言支持通常包括以下层次:
1. 语言识别与基础编辑体验:让编辑器知道哪些文件属于这门语言(文件扩展名关联),并提供注释切换、括号匹配、自动缩进、自动闭合括号与引号等基础编辑行为;
2. 语法高亮:对代码做词法级别的着色,区分关键字、字符串、注释、类型名等;
3. 语言智能:即通常所说的 IDE 功能,包括语法/语义诊断(错误与警告提示)、代码补全、悬浮提示、跳转到定义、查找引用、重命名重构、文档符号大纲等;
4. 外围工具链集成:调试、运行、构建等。
语言插件的质量,一定程度上决定了开发者对一门语言的“印象”——同样一门语言,在一个有精准诊断和补全的编辑器里与在一个只有着色功能的编辑器里,开发体验有天壤之别。语言插件的功能完善程度也是一个语言的生态是否齐全的重要指标。
如果没有语言插件,那么C代码文件在编辑器里看起来会是下面这个样子:

从发展历程看,语言插件并非新鲜事物。早在 VS Code 之前,深度语言支持就是大型 IDE 的立身之本:Eclipse 的 JDT 把 Java 编译器内嵌到 IDE 进程中,实现了增量编译与实时错误标注;Visual Studio 依托 IntelliSense 引擎为 C++ 提供了当时最完善的补全体验。
这些方案的问题在于,语言支持与宿主 IDE 深度耦合——JDT 的分析能力无法脱离 Eclipse 使用,Visual Studio 的引擎也无法移植到其他编辑器。轻量级编辑器(Sublime、Atom)则大多停留在 TextMate 高亮 + 简单补全的层面,语言智能明显欠缺。这种两极分化,恰恰说明行业缺少一个把“语言分析”从“具体编辑器”中解放出来的标准接口——这一缺口正是后来 LSP 填补的对象。
1.2 以 VS Code 为例:插件如何提供语言能力
在VS Code 中,每个语言插件通过清单文件 package.json 中的 contributes 字段,向编辑器声明自己提供的语言能力。
本文以 C 语言扩展为例,其 package.json 中最核心的声明如下:

这段声明完成了两件事:
languages:向编辑器注册一门语言,语言 ID 为 c,关联 .c 与 .h 扩展名;configuration 指向的 language-configuration.json 描述了这门语言的注释符、括号对、自动闭合规则等编辑器行为,例如:

grammars:将语言 ID 与一份 TextMate 语法文件绑定(即 c.tmLanguage.json),用于语法高亮。这部分我们将在后文中展开。
完成这两个声明后,重新加载 VS Code,打开任意 .c 文件,右下角就会显示语言为“C”,且编辑器已经具备注释切换、括号高亮、语法着色等能力。

下图显示了VSCode内置的C代码的高亮功能的效果,相比无高亮的代码文本来说,识别度高了很多,极大减轻了研发的心智负担。

1.3 声明式能力与程序化能力的边界
然而,上文中涉及的这些声明式能力很快就到了天花板。language-configuration.json 无法告诉你“这个变量未定义”,TextMate 语法也回答不了“补全列表里应该有哪些结构体成员”。凡是需要理解代码结构的功能——诊断、补全、跳转——都必须编写真正的分析逻辑,也就是“程序化能力”。
实现程序化能力的第一反应,往往是在插件的主进程里直接写分析代码:监听文档打开、编辑、保存事件,拿到文本后自己做词法/语法分析,再调用 VS Code 的各类 Provider API注册结果。对于小型语言或简单需求,这种模式确实可行;但一旦语言分析本身比较重(需要完整解析、跨文件类型检查、甚至调用独立的编译器内核),问题就暴露出来:
· 性能问题:分析代码运行在编辑器扩展主进程中,与 UI 事件循环共享一个线程。一次全项目分析动辄数百毫秒甚至数秒,会直接造成编辑器卡顿;
· 架构问题:语言分析与编辑器 API 耦合在同一个进程里,无法独立测试、独立升级,更无法把已有的编译器基础设施(往往用 C++/Rust 等系统语言编写)直接复用进来;
· 复用问题:这些分析逻辑与 VS Code API 紧紧绑死,换一个编辑器就得从头再来。
如何把“语言分析”从编辑器中干净地剥离出来?这正是LSP登场的背景。
02
Language Server Protocol
2.1 传统方式的困境
在 LSP 出现之前,每个编辑器生态都有自己的插件体系与 API:VS Code 的 Extension API、Vim 的 Vimscript/脚本接口、Emacs 的 Elisp、Sublime 的 Python 插件……假设有 M 个编辑器、N 门语言,若想让每门语言在所有编辑器中都获得完善的智能支持,理论上需要开发 M×N 份语言插件。
这是历史上真实发生过的生态碎片化。同一种语言的补全逻辑,要在 VS Code 里用 TypeScript 写一遍,在 Sublime 里用 Python 写一遍,在 Emacs 里用 Elisp 写一遍——而且每份实现的完成度、行为、bug 都不一样。对 C 这样的主流语言尚且如此,对小众语言而言,为每一个编辑器都维护一份深度插件成本高到不可接受,结果往往是只在某一个编辑器里有一份"能凑合用"的插件。
问题的本质在于:语言分析逻辑与编辑器 API 之间没有统一的接口约定。语言智能(解析、补全、诊断)的核心知识其实只依赖代码文本本身,与它跑在哪个编辑器里毫无关系。
2.2 LSP 的诞生
2016 年,微软在开发 VS Code 的 TypeScript 支持时正式提出了语言服务器协议(Language Server Protocol),并把它作为一个开放标准发布。其核心思想非常朴素:
把所有与语言相关的智能分析逻辑,放进一个独立的进程(称为语言服务器,Language Server);编辑器(称为客户端,Client)通过一套标准化的 JSON-RPC 协议与这个进程通信。协议中约定:客户端如何把"用户打开了哪个文件、修改了什么内容、光标在哪里"告诉服务器,服务器又如何把"诊断、补全、定义位置"等结果回传。
这样一来,每个编辑器只需实现一次 LSP 客户端,每门语言只需实现一次语言服务器,任意组合即可工作。
值得强调的是,LSP 并非微软的私有方案,而是托管在公开规范下的开放协议(当前最新版本为 3.17),社区中还成立了专门的工作组持续演进。
今天主流语言的服务器几乎都遵循 LSP:C/C++ 的 clangd、Rust 的 rust-analyzer、Go 的 gopls、Python 的 pyright、Java 的 jdt.ls 等等。VS Code、Neovim(内置 LSP 客户端)、Emacs(lsp-mode/eglot)、Helix 等编辑器也都内置或通过插件提供了 LSP 客户端能力。
2.3 LSP 协议速览
LSP 的传输层是 JSON-RPC 2.0:每条消息是一个 JSON 对象,带有 jsonrpc、id、method、params 等字段,支持请求/响应(双向调用)与通知(无需响应)两类消息。在此基础上,LSP 定义了一组标准方法,常见的有:
类别 | 方法名 | 作用 |
生命周期 | initialize | 握手,交换双方支持的能力(capabilities) |
文档同步 | textDocument/didOpen / didChange / didClose | 同步文档的打开、增量修改与关闭 |
诊断 | textDocument/publishDiagnostics 或 textDocument/diagnostic | 推送或拉取语法/语义诊断 |
补全 | textDocument/completion | 请求补全列表 |
悬浮 | textDocument/hover | 请求光标处符号的悬浮文档 |
跳转 | textDocument/definition | 请求符号的定义位置 |
协议中还贯穿了一个重要的设计——能力协商(Capability Negotiation):客户端在 initialize 请求中声明自己支持哪些特性,服务器则在响应中声明自己提供哪些特性,双方只在实际支持的能力范围内通信。这保证了协议可以在特性差异很大的编辑器之间平滑工作,也让协议本身可以不断扩展而不破坏旧实现。
下面给出两条真实的LSP消息作为例子:
这是客户端告知服务器"用户打开了一个文档"的通知:

第二条是服务器在分析后返回某文件的诊断列表(请求-响应对中的响应部分):

可以看到,LSP协议的内容就是纯 JSON 数据——编辑器与服务器之间没有魔法,只有约定。severity: 1 对应报错的Error级别,range 的行列均从 0 开始计数。整个 LSP 规范,本质上就是把这样一批"方法名 + 参数结构 + 返回结构"的约定整理成册。
03
LSP的优点
在“编辑器与语言解耦”这个总目标之下,LSP 带来了以下几个层面的实际收益。
其一,一次开发,处处可用。 如前所述,语言逻辑只需实现一次,即可服务于所有支持 LSP 的编辑器。以 C 语言的实践为例:深度分析能力由 C++ 编写的编译器前端(下文称 Core,工程上可以是自研解析器,也可以封装 clang 一类现成前端)承担,而我们只需围绕它封装一个 Node.js 语言服务器,VS Code 端即获得完整的诊断能力;未来若要支持其他编辑器,语言服务器无需任何修改。这也是 clangd 能够同时服务 VS Code、Neovim、Emacs 的原因。
其二,进程隔离,守护编辑器体验。 语言服务器是独立进程,分析再重也不会阻塞编辑器的 UI 线程;服务器崩溃时编辑器本体不受影响,客户端可以自动重启服务器(VS Code 的 LanguageClient 默认就提供了错误续跑、断线重启等策略)。这与插件主进程直接进行分析的做法形成鲜明对比。例如C语言的项目一旦引入跨文件 #include 与宏展开,分析成本会明显上升;把这些工作放在独立进程里,才能在长时间类型检查并发执行时依然保持编辑器流畅。
其三,充分复用编译器基础设施。 语言的精准智能(类型检查、跨文件引用、语义错误)本质上就是编译器前端做的事。LSP 的进程模型允许语言服务器直接调用(或封装)现成的编译器/分析器内核,而不必在插件层用脚本语言重新实现一遍解析器。C 插件中,诊断结果的权威来源就是 Core 内核的合规性检查(legal check / 语法语义检查),插件层只负责搬运与呈现。
其四,实现语言无关、技术栈自由。 协议只约束消息格式,不约束实现语言:服务器可以用 Node.js、Python、Rust、Go 甚至 C++ 编写。社区还提供了成熟的协议库(如 vscode-languageserver、tower-lsp),把 JSON-RPC 编解码、文档同步等通用逻辑都封装好了。
其五,可测试、可部署、可超越编辑器。 因为服务器是一个“输入文本、输出分析结果”的独立程序,它可以脱离编辑器进行单元测试与集成测试,也可以在 CI 流水线中直接调用,甚至可以服务于 Web IDE、代码审查工具等非传统编辑器场景。
其六,能力协商带来平滑演进。 新的协议特性可以由客户端与服务器按需启用,老版本编辑器配新服务器、新编辑器配老服务器都仍然可用,生态升级不必一刀切。
当然,LSP 也并非没有代价:跨进程通信天然带来序列化开销与异步复杂度,文档同步、请求取消、状态管理等细节需要认真处理;对理解程度要求极高的特性(如精确语义高亮)早期协议覆盖不足,需要通过扩展(如后来并入主协议的 Semantic Tokens)持续补齐。但总体而言,它仍然是当前开发语言插件时性价比最高的架构选择。
04
基于LSP的语言插件的一般架构
4.1 总体架构
一个典型的 LSP 语言插件由三层构成:编辑器(客户端)、语言服务器、语言分析内核。以开发VSCode中的 C语言插件为例,其基本架构如下:

三个职责边界非常清晰:
· 客户端(扩展主进程):负责与 VS Code API 打交道——声明贡献点、启动语言服务器、转发编辑器事件,并把服务器返回的诊断/补全等结果交给编辑器呈现。也可以在此补充一些不经过 LSP 的 UI 能力(如树形视图、装饰器、CodeLens)。
· 语言服务器:负责协议交互与语言分析。它维护当前打开文档的内存视图(TextDocuments),在合适的时机触发分析,把结果转成 LSP 数据结构回传。
· 分析内核:真正懂语言的部件,通常是编译器前端。语言服务器通过子进程、FFI 或管道调用它。我们的项目中,Core以可执行文件形式提供,服务器与其通过参数文件 + 结果文件交换 JSON 数据。
4.2 客户端:启动并管理语言服务器
客户端使用 vscode-languageclient/node 库(与服务器端的 vscode-languageserver 是官方配套的一对库)。核心代码示例如下:

几个关键点:
· TransportKind.ipc:客户端与服务器通过 IPC 管道通信。vscode-languageclient 也支持 stdio 与 socket 两种方式,其中 stdio 最通用(任何语言写的服务器都能用),ipc 则是 Node.js 场景下最省事的选择;
· documentSelector:声明只有 file 协议下的 c 语言文档由本服务器负责。编辑器据此决定向谁转发请求;
· 容错策略:ErrorAction.Continue 与 CloseAction.Restart 让语言服务器故障不影响编辑器使用,体现了进程隔离的容错价值;
· 打包方式:扩展主程序与语言服务器是两个独立入口(dist/extension.js 与 dist/server/server.js),由 esbuild 分别打包,vsce package 时一同装入 .vsix。
4.3 服务器:协议层与能力声明
服务器端使用 vscode-languageserver/node,代码结构如下:

onInitialize 的返回值就是能力协商中服务器这一侧的声明:这里声明了增量同步与诊断两种能力。如果之后要支持补全,就在 capabilities 中追加 completionProvider。
值得一提的是初始化的完整时序,它是整条通信链路的地基:客户端启动服务器进程后,必须先发送 initialize 请求并等待响应,服务器此时才能通过 params.capabilities 获知客户端支持什么(例如是否支持工作区文件夹、是否支持相关信息的诊断),并把自身的能力随响应返回;客户端收到响应后再发送 initialized 通知,此后业务消息(didOpen、补全请求等)才允许流动。在 initialized 回调里,服务器通常会完成一些依赖客户端能力的准备工作——本项目在这里注册了配置变更监听,用于接收分析超时时间日志级别头文件搜索路径等配置的实时更新。协议对时序的严格要求避免了服务器还没准备好就被请求轰炸这类竞态问题,vscode-languageserver 库会自动拒绝在 initialize 之前到达的业务消息,相当于为开发者做了兜底。
4.4 消息流转全貌
一次典型的工作过程如下:
1. 用户在 VS Code 中打开 foo.c → 编辑器判定语言为 c → 客户端向服务器发送 textDocument/didOpen(携带全文);
2. 用户输入字符 → 客户端发送 textDocument/didChange(增量:只带变更的片段与位置区间);
3. 服务器(在防抖后)执行分析,客户端通过 textDocument/diagnostic 请求或服务器主动推送获得诊断列表,编辑器将其渲染为红色波浪线与问题面板条目;
4. 用户触发补全/悬浮/跳转 → 编辑器发出对应请求 → 服务器返回结果;
5. 用户关闭所有该项目的文档 → 客户端通知服务器清除相关诊断缓存。
理解了这套架构,剩下的问题就是在服务器的哪个钩子里、做什么分析、返回什么结构。

05
基于 LSP 实现基本的语法诊断功能
诊断(Diagnostics)是语言插件最基础也最有价值的语言智能:编辑器中的红色/黄色波浪线、问题面板中的错误列表,都来源于它。
5.1 语法错误的检查
5.1.1 分析来源:调用编译器内核
LSP 只负责传输诊断,不负责产生诊断。真正的语法/语义分析应该交给解析器或编译器内核。在 C 语言插件中,服务器可以通过一个桥接层(Bridge)调用真正执行的 Core 内核,我们这里假定语法诊断的实际能力通过调用一个可执行文件来实现,例如:

内核返回的 JSON 结构例子如下:

这里体现了一个工程上非常重要的原则:内核与 IDE 层使用各自的坐标与格式约定,桥接层负责翻译。
还需要回答一个问题:为什么检查的粒度是全项目而不是当前文件?因为 C 是典型的跨文件语言——main.c 里调用的函数可能只在 util.h 中声明、在 util.c 中定义,#include 引入的类型、宏和内联函数可能来自项目内任意头文件。只检查单文件,调用了未声明的函数包含了不存在的头文件结构体类型不完整这类跨文件错误就会漏报;即便只报单文件语法错误,符号解析的正确性也依赖预处理与全量上下文。因此C语言项目的语法检查应始终以项目源码目录为单位进行(工程上还可叠加 include path、compile_commands.json),这也在协议侧带来了相应的设计(见下一小节 interFileDependencies 声明)。
5.1.2 诊断模式:推送与拉取
LSP 提供两种诊断交付方式:
· 推送式(Push,publishDiagnostics):服务器在分析完成后,主动把诊断推给客户端。这是 3.17 之前唯一的方式,简单直接;
· 拉取式(Pull,3.17+,textDocument/diagnostic):客户端在需要时(文档打开、变更、用户查看问题面板等)主动向服务器拉诊断,服务器可声明 interFileDependencies(诊断依赖多个文件)与 workspaceDiagnostics(支持全工作区诊断)。
我们以拉取式为例进行说明:语法的分析在服务器内部异步进行,结果写入缓存;客户端发起拉取请求时直接命中缓存返回,避免把耗时的内核调用阻塞在请求链路上。服务器在 initialize 中声明并注册拉取处理器:

5.1.3 从内核结果到 LSP Diagnostic
上文中,诊断处理函数 performDiagnostics 是语法诊断过程的重要步骤,它在内部完成了提取文本、调用内核、转换格式、更新缓存四步:

至此,一条语法错误的完整旅程是:
1. 用户输入:用户打开或修改代码文本;
2. 触发didOpen/didChange生命周期;
3. 防抖处理;
4. Bridge层调用 Core 内核;
5. JSON 结果;
6. 坐标翻译与 Diagnostic 构造;
7. 将诊断结果进行缓存;
8. 拉取响应;
9. VS Code 渲染波浪线与问题面板。
5.2 语法的智能提示
如果说诊断是被动纠错,那么智能提示(IntelliSense)就是主动辅助。本节以最具代表性的代码补全为例,说明如何在 LSP 服务器中实现一个交互式语言特性。
5.2.1 声明能力并注册处理器
补全的实现同样遵循能力声明 + 请求处理两步。首先在 onInitialize 的 capabilities 中声明:

triggerCharacters 的意义在于:用户输入 .、>(配合 -> 做指针成员补全)或 #(预处理指令)时,编辑器无需等用户手动按 Ctrl+Space 就会向服务器发起 textDocument/completion 请求。
然后注册处理器:

这段代码展示了补全实现的两个核心动作:
· 上下文判断:拿到触发位置后,先分析光标前是什么,再决定给哪一类候选。上下文判断的精细程度直接决定补全的聪明程度——最朴素的版本是永远返回全部关键字和当前文件所有单词,进阶版本则依赖语法树、预处理结果与符号表;
· 候选构造:每个候选项是一个 CompletionItem,除 label 外还可以带 kind(决定显示什么图标)、detail(右侧灰色说明)、documentation(选中时的悬浮文档)等。CompletionItemKind 是协议内置的枚举(Method、Field、Struct、Keyword、Snippet……),编辑器据此渲染出与原生语言一致的图标语义。
06
代码高亮的实现
在 VS Code 的体系里,语法高亮不经过 LSP,而是由编辑器内置的 TextMate 语法引擎完成。尽管经过前面的介绍,我们已经可以实现一个拥有基本能力的语言插件,但对于一般研发来说,最为直观的体验还是语法高亮本身。
6.1 为什么高亮不走 LSP
代码高亮不通过LSP有两层原因:
其一是历史与架构:TextMate 语法定义源自 TextMate 编辑器,Sublime、Atom 乃至 GitHub 的代码着色都沿用这套格式,VS Code 继承了它。它在渲染层工作:编辑器需要对当前视口内的文本做即时的、逐行的、廉价的分词着色,而 LSP 的分析在语义层,粒度重、时延高,两者定位不同。其二是成本收益:词法级高亮用正则描述就足够好,没必要为每个字符什么颜色启动一次跨进程请求。
不过 LSP 并没有完全缺席这个领域。LSP协议后来加入了 Semantic Tokens(语义高亮)特性:服务器可以基于真正的语法树/符号表,返回某位置某长度的 token 属于某个语义类别的紧凑列表,编辑器将其叠加在 TextMate 着色之上,用于修正 TextMate 无法区分的情况(例如区分局部变量与函数参数、标识宏展开出的标识符、标记未使用的符号)。当前主流做法是:TextMate 负责 90% 的基础着色,语义高亮做锦上添花的修正。
6.2 TextMate 语法的工作原理
一份TextMate语法约定是一个JSON文件(例如 c.tmLanguage.json),核心概念有三个:
· scope name:语法的全局标识(如 source.c),主题按 scope 名称匹配颜色;
· patterns:一组带作用域名的正则规则,自上而下依次尝试匹配;
· repository:可复用的子规则库,通过 include 引用,支持递归。
以C语言插件项目中的关键字规则为例:

规则的三要素一目了然:match 是正则表达式,name 是赋予匹配文本的作用域,多个 pattern 按声明顺序优先匹配。作用域采用从具体到一般的点分命名(keyword.control.c ⊃ keyword.control ⊃ keyword),主题只需匹配任意前缀即可,这使得同一份语法能适配任意主题。
对于字符串、块注释这类有开始有结束的结构,则使用 begin/end 对规则,且支持递归嵌套。本项目中的块注释规则就是自嵌套的:

字符串规则则演示了 beginCaptures/endCaptures 与子模式的配合:

最后,在 package.json 中把语法与语言绑定。编辑器渲染时,用语法规则对文本分词、给每个片段打上作用域标签,再由主题(theme)把作用域映射为颜色——例如经典主题会为 keyword.control 给出紫红色、string 给出橙红色。换主题不换语法、换语法不换主题,两者彻底解耦。
07
结 语
本文以VSCode中的C语言插件为例子,完整走过了编辑器语言插件→ LSP 协议 → 插件架构 → 诊断与提示实现 → TextMate 高亮的全链路。总结几条最有工程价值的经验:
1. 架构先行:语言插件的标准形态是声明贡献点 + LSP 客户端/服务器 + 分析内核三层结构。把语言智能放进独立的服务器进程,换来的是性能隔离、崩溃容错与跨编辑器复用;
2. 内核为源:诊断等语言智能的权威来源应当是编译器内核,插件层专注翻译与呈现——尤其是数据结构的转换;
3. 善用协议机制:能力协商、增量同步、拉取式诊断、防抖,这些机制共同保障了大项目 + 高频输入场景下的流畅体验;
4. 分而治之:高亮交给 TextMate,语义智能交给 LSP,宿主 UI 能力(树视图、装饰器、CodeLens)留在客户端——每个部件放在它最合适的位置。
LSP 自 2016 年发布以来已成长为编辑器生态事实上的语言接入标准。掌握协议的运用不仅适用于开发一门语言的插件,也是构建任何编辑器 + 工具链集成时都值得借鉴的架构思路。

