用 Go 移植 TypeScript 的重要影响

用 Go 移植 TypeScript 的重要影响 | imbAnt’s blog

来填坑了(雾)

首先先介绍一下tsc,

TypeScript = JavaScript + 类型系统(Type System)
Javascript是动态类型语言,tsc通过引入静态类型和类型注释,解决了jsc缺少类型检查等问题

但是在构建 Web 前端应用、Node.js 应用时,都需要把 TypeScript 编译为 JavaScript


在 VS Code、Cursor 这样的代码编辑器中编码时,TypeScript 也在基于 LSP 提供语言服务,具体来说是通过一个被称为 tsserver 的语言服务器实现的。
在每次按键时的代码补全、跳转到定义、错误提示等等,都需要 tsserver 编译和分析,并提供实时反馈。

受限于 JavaScript 动态语言的性能,在大型应用中,语言服务器会有性能瓶颈,这也是在写代码时,有时补全很慢才能出来的原因。而利用原生语言(无论是Golang还是C#还是Rust),加上 ts 自身的优秀架构和设计,这一点会有很大改善。


TypeScript 到 LSP 的标准化

TypeScript 和 LSP 都是微软家的技术。事实上,作为一个语言服务器,tsserver 并没有严格兼容 LSP。原因是 tsserver 的出现早于 LSP,而 LSP 正是受到了 tsserver 的启发:与编辑器的 UI 进程分离,在独立的进程中提供智能编程服务。

然而,官方提到,希望借助这次移植,完成兼容 LSP 的愿景。这意味着,未来的 tsserver 将会是一个标准的 LSP 语言服务器。

没能兼容 LSP,是 TypeScript 的短板。也就意味着难以发挥 LSP 跨客户端(代码编辑器)的优势。
另外,我认为兼容 LSP 后,tsserver 还有一个重要的作用,就是与 AI 辅助编程 结合。


强化 AI 辅助编程

以 GitHub Copilot 为例,看看现在(2025 年 5 月)AI 辅助编程的功能:

  • 内联代码补全(inline completion)
  • 聊天(chat)/编辑(edit)
  • 代理(agent)
    其中,内联补全是最早惊艳到用户的功能。本质上,它的原理是利用 LLM 生成建议的代码。
    核心在于构建 prompt,让 LLM 生成用户真正想要的补全。

Copilot 做了很多努力来让 prompt 更精准。所谓的更精准,一个方面就是要贴近用户正在输入的代码的语义。例如,收集光标附近的代码、引用的依赖、打开过的文件、相关函数的签名、注释等等信息,将最有效的信息提炼出来,生成 prompt。

我有一个想法,其实语言服务器非常擅长为 prompt builder 提供这些语义信息:

收集全量的语义信息

由于 token 数量、性能等的限制,Copilot 难以全量的分析本地的巨型应用(build local workspace index),即使有 remote index,也有各种各样的限制,例如要上传到 GitHub 等;
而和编译器一样,语言服务器需要编译工程,天然会解析整个工程的语义信息,来提供智能编程服务。

当然,语言服务器要解析整个工程,并不意味着一定会全量编译工程里的每个文件,例如可以先预编译 一个文件,得知导出的签名信息(变量类型、函数签名等),后续按需(例如用户打开这个文件时)完整编译

处理用户交互

语言服务器比起编译器的一大区别就是擅长处理用户持续不断的编码。借助增量更新技术(Incremental build),语言服务器可以按需编译,仅计算有变化的数据,高效地动态构建语义信息,确保及时更新。例如 tsserver 就支持增量编译

举个简单的例子,当用户删除了某个函数中的一行,这个函数本身的签名并没有变化,那么,只需要更新这个函数内部的语义信息即可。

这个能力对于 Copilot 来说也是非常重要的:随着用户编码,prompt 中的上下文也要及时更新。

function calling 和 MCP 支持
除了内联补全,Agent 模式也是最近的热点,借助 function calling,LLM 能更精准的获取上下文信息。

例如,VS Code 自带一个 Find Usages(tool、function calling…随便怎么叫),可以是让 LLM 查找某个符号的定义、引用等等位置。

这听着是不是非常耳熟?这不就是 LSP 中的跳转到定义(textDocument/definition)和查找引用(textDocument/reference)吗?
语言服务器的工作,就是要服务客户端,随着用户不断编码,持续提供语义查询等功能,进而实现智能编程能力的。将这些能力封装为 function calling,供 LLM 查询语义信息,也就是一件非常自然的事情。
事实上,社区已经有一些这样的尝试,例如 mcp-language-server,借助 MCP 标准化语言服务器和 LLM 的通信方式,让语言服务器也同样成为 MCP 服务器。

当然,除了借助 MCP 的通用方式,在 VS Code 内也有个轻量的方法,来提供 function calling 能力,也就是由 VS Code 插件自行实现,插件直接通过配置文件(json)描述能提供哪些 tool。

不过考虑到 tsserver 要兼容 LSP 的雄心壮志,我觉得官方会更倾向实现 MCP 服务器的方式,让更多编辑器都可以使用这个能力,而不仅是 VS Code,当然,作为微软的产品,VS Code 的适配还会是首选。

不论是 MCP,还是 Go 移植,都还在非常早期的阶段。我非常看好这个项目,我认为到 2026 年,借助原生移植、与 AI 结合,编写 TypeScript 的最佳方案是 VS Code + GitHub Copilot,不是 Cursor,更不是 WebStorm。

下面是对LSP等先验知识的详细介绍

FROM
LSP 与 VS Code 插件开发(一)语言服务器架构 | imbAnt’s blog

我是一名语言服务器开发者、VS Code 插件开发者。
我开发了这款插件。它集成了一个基于 LSP 的语言服务器,可以为自研编程语言提供智能编程功能。

如果这些名词对你来说还很陌生,可以考虑这个问题:
当你在电脑上首次安装 VS Code,创建一个 .vue 文件,会发现在其中编写代码是非常困难的——体验几乎和面试时要求你白板写代码一样痛苦!

  • 黑纸白字,变量都是一个颜色
  • 想要打印日志,需要在键盘上按 c-o-n-s-o-l-e-.-l-o-g 一共 11 次,才能输入需要的函数名
  • 单词拼错了,console 拼成 consola,webpack 编译报错,但编辑器没有错误提示

这太痛苦了!单独写 HTML 或者 JS 都没问题,为什么 vue 文件就这么难写呢?

这时你搜索到一个插件:Vue - Official,安装后,这些问题都解决了:变量、标签都有了各自的颜色,输入 con 就能补全出 console,写错的 consola 也被红色波浪线标识了。

这些功能叫做

语法高亮
语义高亮
代码补全
错误提示
当你深入使用 VS Code 编码后,会发现更多智能编程功能,比如

跳转到定义
悬浮提示
查找引用/查找实现
函数签名提示
重命名

Vue 插件做了哪些事情,才让你的编程体验变得如此美好呢?为什么一定要安装插件才能有这些功能呢?为什么 .ts、.html 文件就不用安装插件?

这就引出了另一个问题:

假设你要发布一门全新的技术,引领新的技术潮流,用户需要在全新类型的文件中编码——就和 .vue 还有 .astro 做的事情一样,那你一定要解决这个问题:避免用户在全新文件中编码时,体验和白板写代码一样痛苦。

问题来了,你要怎么做?需要写一个 VS Code 插件吗?如何实现这些智能编程功能?那些喜欢用 Vim 写代码的人又怎么办?

VS Code 插件 —— 能用 js 实现的,最终都会用 js 实现
Any application that can be written in JavaScript, will eventually be written in JavaScript - Jeff Atwood

我们先从 VS Code 入手,看看它是如何设计,使得开发者能接入新语言的:它通过出色的插件系统,将内部代码封装成接口暴露出来,供插件调用。这些 API 就包括了高亮、代码补全、错误提示等等功能。这些功能都是数据驱动的,换句话说,这些智能编程功能,具体是由 VS Code 开发者一行一行代码实现的,将输入数据转化为 DOM 操作;而插件开发者无需关注细节,只需要提供符合格式的数据即可。

例如,你可以写一个简单的插件,让注释里的 TODO 高亮显示:逻辑非常简单,匹配文本中的 TODO 字符,记录它的行列号,给出高亮的颜色。事实上这个简单的插件已经有人写了,并且有 400w+ 的下载量。

从技术栈来说,VS Code 是一个 Electron 应用,由 HTML、CSS 和 JS 构建。这意味着那些强大的智能编程功能,都可以由前端技术栈实现,只需要学习一些 VS Code API,你就能开发出自己语言的插件。

HTML、CSS、JS 的内置支持
回答刚才的问题,为什么 VS Code 认识 HTML 文件?同样是前端技术构建的应用,VS Code 已经对常见的代码文件有了内置的支持——内置了 ts、css、html 的插件,天然支持这些语言的智能编程功能。你可以在 VS Code 内按 F1,输入Show Built-in Extensions,查看全部内置插件。

插件架构

先看这张图的左边:Electron 应用有主进程和渲染进程之分;我们看到的编辑器窗口,就是一个渲染进程。渲染进程又新开了一个插件专用的子进程,叫做 Extension Host,你用 Node.js 编写的插件就在这个子进程中运行。

打开你的 VS Code,看看你现在有多少个插件?我有 73 个。

事实上,这 73 个插件都运行在同一个进程,也就是 Extension Host 里。这意味着

  1. 插件行为与渲染进程独立,插件通过 IPC 影响页面表现,插件 crash 不会影响到用户正常编码(只是智能编程功能可能就挂了,例如写 TS 时偶尔会遇到悬浮提示、代码补全都一直 loading)。
  2. 各个插件之间共享上下文。碰到过很有意思的情况是,我在自己的插件中部署了 Sentry,而它捕获到了 json 插件的报错。
  3. 运行缓慢的插件,虽然不会拖慢渲染进行的速度,但由于提高了整体插件进程的开销,会拖累其他插件的速度。
  4. 同样由于共享资源,单个插件处理 CPU 密集型任务时性能不好。

分享几个插件管理小技巧:

  • 查看启动性能:VS Code 指令 Show Running Extensions 可以列出插件的启动时间,也有 Profile 能力,用来查看运行缓慢的插件。
  • 二分法检查问题:当 VS Code 用起来总是有问题,还有个二分查找法功能,用来关闭一半的插件,看看问题是否解决。持续二分,直到找到问题插件。

语言服务器 —— 服务任何代码编辑器

前边提到了 VS Code 插件的一个痛点:处理 CPU 密集型任务时性能不好。而 VS Code 要求插件不能影响 UI 响应速度。
还有另一个问题,我们一直在讨论 VS Code 内的开发,又如何应对其他代码编辑器的用户?那些使用 Vim、Atom 的用户,难道要强制他们安装 VS Code 吗?这似乎也不是个优雅的方案。
此外还有更致命的问题,插件只能使用 JS 编写,可如果新技术并非使用相关的技术栈呢?
如何让 VS Code 支持 PythonJavaGo 等语言?Google 推广 Go 时,又该如何让多个编辑器支持这门语言?

微软想出一个方案来解决这三个问题:Language Server Protocol(语言服务器协议)

首先,创建一个独立进程:在独立进程中计算,来获取更多的 CPU 和内存资源,以提供语言服务。这个进程,我们叫做 Language Server(语言服务器)。由于是脱离了 VS Code 的独立进程,到底由什么编程语言编写就没有限制了。

那么编辑器(语言客户端)和语言服务器之间,就需要进程间的通信。如果你已经读过 VS Code 的官方教程就会发现,“用数据描述智能编程功能”是一件相当复杂的事情,双方需要相互告知大量且丰富的数据。

例如跳转到定义,客户端会告知当前光标的行列号、当前的文本内容;服务器需要返回定义的行列号。

这就需要一个协议,规定客户端和服务器之间通信的数据格式,这就是 LSP 了。它和 HTTP 协议有点类似,但更轻量,仅用于智能编程功能的通信。

挟 LSP 以令编辑器

接下来就需要客户端,也就是代码编辑器们需要适配此协议了。LSP 是微软推出的标准,旗下的 VS Code 自然是首先支持的编辑器。Vim、Atom 等编辑器也有了很好的支持。

但推行这个标准不是一帆风顺的。BetBrains 家的付费编辑器,例如 WebStrom,对 LSP 的支持就不太积极,从 2023 年,才开始支持部分 LSP 功能。

现在,我们的 VS Code 语言插件的架构比较明了,重新看这张图:

  • 左边是 VS Code,它的渲染进程起了子进程 Extension Host,运行多个插件
  • 插件作为语言客户端,其业务逻辑非常薄,功能就是启动语言服务器,并基于 LSP 与语言服务器通信
  • 语言服务器是一个独立进程,实现了最核心的业务逻辑,可以用任何编程语言编写,利用独立进程的算力,计算出客户端所需的数据,返回给客户端

说了这么多,其实还有一个核心问题没有解决:语言服务器做了什么事情?考虑刚才的跳转到定义的协议,客户端仅仅输入了当前光标的行列号和文本内容——甚至没有说明选中的是哪个函数、变量或是空白处,服务器就返回了其定义的行列号。

因此语言服务器需要编译源码,构建语义模型,为客户端提供智能编程服务

所谓的 编译 是怎么回事?它和编译器是什么关系?本章会和大学里的编译原理知识有些关系,但保证比课本上的更有趣、更好玩!

语言服务器与编译器的关系

先回顾一下编译原理的基本流程:
词法分析语法分析语义分析中间代码生成代码优化目标代码生成...

前端相似

具体各流程的作用就不赘述了,大学课本里那些长篇大论介绍 LL(1) 文法过于乏味。
我们基本可以把编译器的工作分为前端、(中端)、后端几个流程,事实上,编译器和语言服务器在编译的过程中,前端的过程是非常相似的。也就是 词法分析语法分析语义分析 几个阶段。

通过这三个阶段,从源码中获取语义信息后,语言服务器等待客户端请求,为编程体验服务,而编译器则是继续中端、后端,为生成目标产物服务。在这之后,两种做的事情就大相径庭了。

换句话说,对于 console.log 这一行代码,两者都经历了这样的阶段:

阶段 进度
源码 console.log
词法分析 IDENTIFIER DOT IDENTIFIER
语法分析 规约为 DomainDotAccess,是通过点号访问域的语法
语义分析 console 符号是一个接口,log 符号是其中一个方法

这里的 DomainDotAccess 是个示意,实际上可能是 MemberExpression 或者 PropertyAccessExpression 之类的,只是一个命名问题。

接下来,有了语义信息后,语言服务器就可以:

  1. 前 7 个字母涂成红色;后三个字母涂成蓝色 console.log → console.log
  2. 鼠标悬停到后三个字母的位置,提示 Prints to stdout with newline.
  3. 校验实际参数的数量、类型是否与函数签名相符、返回值类型是否正确

而这些都是和编译器完全无关的事情。

容错

此外,两者的容错处理也是不一样的。当源码中出现了错误:
编译器会停止编译,通过标准输出等方式抛出编译错误,自然也没有目标产物的输出了,当然,一行代码的改动可能引起数个文件的错误,如果只遇到一个错误就停止编译,也不利于 debug,往往会尝试尽可能多的抛出错误;
而用户会持续不断的编码,你可以想一下,输入编写一行代码时,可能只有输入了最后一个分号 ; 后,编译才会通过,而这期间语言服务器则会持续工作,进程不会停止,而是收集 Diagnostic,也就是诊断信息,客户端根据这些信息,在代码编辑器上显示红色或者橙色的波浪线。

通常来说,编译器能顺利编译通过的工程,语言服务器也不应出现诊断。反过来,代码编辑器中没有波浪线,理论上编译也是能过的。
碰到代码编辑器中标红,但能编译通过的情况倒是还好,如果代码编辑器没问题,但编译时报错,那就痛苦了。这种一般都是由于编译器和语言服务器没有统一编译标准导致的,可能是两者编译配置不同,同样一段代码,语言服务器认为是 warning,但编译器认为是 error,甚至是两者的语言版本不同(比如 python2 和 python3)。
另外,像 VS Code 这样的编辑器都支持插件,可能除了语言官方的语言服务器,还有别的(例如 lint 工具)在同时工作,这也会导致代码编辑器里看到的诊断比编译输出的多。从这个角度上,编译器和语言服务器的一致性也是工程化的一个重要问题。

这里可以看到,语言服务器的工作和运行时是无关的。换句话说,使用记事本写的 Hello World,没有语言服务器参与,经过编译器编译后,同样可以执行,而语言服务器提供的只是更好的编码体验,也就是 DX(developer experience)。

总的来说,编译器面向运行时一次性执行,要求最终正确性和可执行性,而语言服务器在用户编码时持续服务,要求及时性和友好性。

使用同一种语言构建

上一章提到,语言服务器与编译器往往是同一种编程语言构建的程序。现在你应该更理解了,在编译原理前端,两者的逻辑高度相似,往后才开始异化。使用同一种语言,甚至在同一个工程中,能最大的复用代码,减少维护成本。这也是促成语言服务器架构的原因之一。
事实上很多语言或是编译器内置语言服务器,例如 DenoTypeScript ,或是在内置工具链中就有语言服务器,例如 Go 和 Gopls、Rust 和 RLS。

当然,语言服务器的实现也并不仅是官方一种,VS Code 就受不了 PHP 解析器不能容错,直接新写了一个语言服务器

深入理解语法与语义

刚才提到了语法分析和语义分析,这里再举几个例子详细说明下。

十六进制颜色字面量

假设我们要设计一门新的编程语言,支持十六进制的颜色字面量值。方案如下:
语法:定义为井号 # 后跟多个十六进制字符(0-9, a-f, A-F)。
语义:仅长度为 3 (rgb)、4(rgba)、6(rrggbb)或者 8(rrggbbaa)的十六进制序列视为合法颜色值。

从语法上没有约束十六进制字符数量。好处在于语法规则宽松,便于解析和扩展,并且容错友好。
一套语法描述可能复用于编译器、语言服务器、lint 工具等多个软件,而由于软件功能不同,语义实现则各不相同。因此语法设计应该更宽松和易扩展。

合法,白色
#fff

语法错误,z不是十六进制字符,
#zzz

语义错误,长度不对
#ff
#fffff
#ffffffffffffffffff

类型推导和显式类型声明

再举一个变量定义的例子

1 var a = 5

a 的值是 5,类型是整数。这里没有显式的声明 a 的类型,因此是通过等号右边的表达式的类型推导出来的。
右值是一个字面量,词法分析时可以通过正则匹配之类的方法得知它是一个 token,比如叫 INT_DIGIT。接着语法分析时,得知 INT_DIGIT 就是一个普通的字面量表达式。然后语义分析时,就知道一个整数字面量表达式的类型是整数,这是编程语言运行时就定义好的,无需额外的语法声明的(typeof 5 == "int")。
与之对应的,如果给 a 加了显式的类型声明:

1 var a int = 5

那么语法上就是通过等号左边的 int token,在语义上查找符号表得知是整数类型。接着和右值类型比较,发现是一致的,这样就通过了类型检查。

不论是类型推导或者显示声明类型,a 的类型都是基于词法分析后根据语法结构决定的。前者是分析了字面量值 5 的语义,为整数;后者是分析了类型符号 int 的语义,同样是整数。这两个过程都是基于语法分析,判断语义。

语法错误更为严重

语法错误的影响更严重,例如少了半个括号,会影响后续也许是整个文件的代码的作用域。相比来说,语义错误更可控,能尽量避免蝴蝶效应。

例如这样的 Go 代码,bar 是一个键为 int,值为 string 的字典。

现在有两个典型的语义错误,分别在第 11 行,尝试用整数字面量赋值给 string 类型;和 13 行,尝试将 string 值赋值给 int 类型。

现在我们制造一个语法错误,删掉第 9 行的一个右括号 ],这显然是一个简单的编码失误,但语言服务器不干了,没有正确的语法支持,很难继续构建语义,第 11 行、第 13 行的红色波浪线也消失了。

一个简单的语法错误,导致了整个文件的语义分析失败,这是语法错误的严重性。而且报出的错误信息也只能基于语法,和代码改动毫无关系,这简直是新手的噩梦。

顺便提一句,其实有经验的开发者一眼就能看出问题所在,但而基于编译原理的解析程序则难以做到这一点。我认为这也许会是大语言模型的一个应用场景,结合代码编辑器,帮助代码初学者快速定位语法错误。

语法错误更快出现

语义错误通常比语法错误更晚出现,因为语法错误出现在语法分析阶段,而语义错误在之后的语义分析阶段。

通常编译单个文件,已经能得到完整的语法错误信息,而语义错误可能需要等整个工程的编译才能完全收集到。

例如,有些编程语言里支持“严格模式”,用来增强类型检查、避免隐式错误等。它在语法上可能就是一行字符串字面量或者魔法注释,例如 "use strict" 或者 // @ts-check。它们自身没有语法错误,但是加上这行代码会影响后续成千上万行代码的语义分析,带来数个语义错误。

这里有一个提高语言服务器性能的方法:
将语言服务器拆分成两个,一个面向语法,一个面向语义。
语法服务器很轻量,只负责词法分析、语法分析,有语法错误时立即反馈诊断。另外也可以提供一些完全基于语法的,和语义无关的小功能,例如显示颜色
语义服务器更重,负责全部的语义构建和服务。
将语法诊断和其他小功能从语义服务器中剥离,可以降低语义服务器的开销,也能让轻量的功能更快的反馈给用户。
当然,这不是说语义服务器就不做词法、语法分析了,依然会做,甚至尝试容错,但会更专注在和语义相关的功能上。

Vue 的语言服务器就是这么做的。

语义模型

这里展示一下我做的基于 Node 的语言服务器,编译这段代码后得到的语义模型

graph test {
func A() {
for i = 0, 10, 1 {
if true {
LogInfo(i)
}
}
var a = 1
while true {
a++
}
}

func B() {

}
}

这是一门自研的 DSL 语言,其具体的语义就不做讨论了,但可以看到,在 Node 内存中,我的语义模型是一个大的对象,graphfuncforifwhile 关键字分别开辟了新的作用域,它们层层嵌套,形成一颗树结构。
既然是树结构,深度优先遍历算法就是可行的。第 4 行的 if 语句,要从根节点开始经过 rootfunc Aforif 几个语法节点,才能访问的到。
鼠标移动到第 5 行的 LogInfo 上,就要经过上述的深度优先搜索,根据鼠标位置的行列号,一层一层查找语义模型,得知此位置的语义是一个函数名,悬浮提示出函数签名,按 F12 还能跳转到函数声明的地方。
这个在树结构上层层查找的过程,有点类似语法分析中由上至下的递归下降分析法,从非终结符开始,递归展开,直到终结符;或者反过来,从终结符开始,递归折叠,直到非终结符。但两者还是有本质区别的,语法分析是为了构建语法树,而语义分析是基于语法树,构建语义模型。只不过从实现上,这个语义模型也是一个树结构的,毕竟是基于语法,两者的结构也是相似的。

shadow 机制

有了这个形象的树形结构,我们就可以从新的角度理解编程语言中的 shadow 机制。
shadow 机制通常指在当前作用域和外层作用域中,同名变量的优先级问题。

var foo = 0
while (true) {
var foo = 1
if true {
var foo = 2

foo += 10
}
}
}

第 8 行的 foo 变量最终的值会是 12,也就是改变了第 6 行的 foo,即使外层还有两个同名变量。
在语义分析阶段,第 8 行是一个加法赋值语句,右值是 10,左值是一个符号 x。为了找到这个符号的语义,语言服务器会从语义模型中当前光标位置的就近作用域开始寻找,也就是 if 开辟的作用域中,发现符号表中有一个 foo,它的语义是 int 类型的变量。这样,第 8 行的 foo 符号就是一个变量的语义了,也许会变成 variable 的蓝色;而它的定义位置,根据符号表,得知在第 6 行,按 F12 跳转到定义,光标就会跳转到第 6 行。
另外,得知第 8 行 foo 的语义后,还会做一系列的语义检查,常见的编程语言可能会检查:

  1. foo 是可写的局部变量,可以作为左值
  2. foo 是个整数,可以做加法赋值
  3. 右值是个整数字面量,和左值的类型相同,可以赋值

shadow 机制对于代码补全也有积极意义:在 if 代码块中,输入 fo,语言服务器应该仅列出一个 foo 变量,忽略外层的两个。让我们给最外层的 foo 改名叫 foo001

-- var foo = 0
++ var foo001 = 0
while (true) {
var foo = 1
if true {
var foo = 2

foo += 10
}
}
}

这时候就会补全 if 中的 foo 和外层的 foo001,而不会再出现 while 中的 foo

自己动手?

快速开始

介绍协议本身还是太枯燥了。官方有非常好的实践教程,可以先跑一遍。
也可以直接进到教程里的仓库,直接 vs code 启动,断点看看代码是怎么工作的。这个教程好就好在启动成本很低,能非常顺利的搭建一个实例插件,启动插件和语言服务器。

持续深入

跑完这个教程,你就可以在样例工程里改改写写,尝试 LSP 的各种功能了。想知道有哪些能力,查官方文档是必不可少的。

但是语言服务非常重交互,而协议本身数据驱动,只靠文字是很单薄的,难以描述出提供的用户交互体验,初学者只看干巴巴的接口定义肯定会头晕,不知道这些接口都能干什么。不过也不怪 LSP 官方,毕竟只负责设计协议,具体的实现还得靠客户端(各大编辑器)。

这时候就推荐曲线救国了:先去看看 VS Code 内置 API 教程。作为来自客户端编写的教程,文档全面,内容生动。可以很轻量的快速实现小功能,验证想法。
也就是说,在 VS Code 内实现智能编程,有两种方式,一种是通过内置 API,基于编辑器原生能力实现;另一种是通过语言服务器,基于 LSP 通信,数据驱动实现。前者的优势是架构简单,快速验证,不过只能在插件进程工作,只能用 Node.js 编程,并且在复杂场景会有性能问题。(可以参考系列第一章了解语言服务器架构如何解决这些问题)

可以先看语义高亮教程,了解如何通过原生 API 给每个变量上色。

接着,非常推荐这篇文档,它列出了原生 API 和 LSP 的对应关系。毕竟 VS Code 和 LSP 一样都是微软家的,同根同源,深度集成,很多概念和术语都相通。

文档里详细讲解了 VS Code 支持的语言特性,并且都配上了动图和聊胜于无的文字说明。要说为什么是聊胜于无,还是因为交互太重了,各个功能需要多写 demo 去体验,意会。

从学习和方便调试的角度,可以尝试先用 VS Code API 实现一些功能,翻翻 API 的文档,然后再换成 LSP 实现,两者的文档交叉比对,能更好的体会“设计”和“实现”的差异,更好的理解 LSP。

最后,一定要克隆这个仓库,它覆盖了大部分使用原生 API 实现智能编程的例子。当你的代码没有正常工作,一定要来看看示例怎么写的。

介绍协议

术语声明

本文会多次提到“客户端”、“编辑器”、“VS Code”等词,事先澄清,避免混淆:

在 VS Code 这个软件中,编辑器指负责用户交互,能显示、编辑代码的区域,也就是 monaco 编辑器
而客户端指和语言服务器进程通信的进程,也就是语言客户端。在 VS Code 中,运行在 extension host 中的插件进程承担了这个角色,它会启动语言服务器并与它通信,调用 VS Code extension API,数据驱动地改变用户界面,实现智能编程服务。

LSP

再次正式介绍一下,LSP 是指 Language Server Protocol,语言服务器协议。

所谓协议,是规定了两端通信的数据格式、交互方式。
其核心是 LSP 消息。客户端向服务器请求代码补全列表时的消息大概长这样:

\r\n
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
...
}
}

与 HTTP 类似,LSP 消息也分 HeaderBody 两部分。

LSP Header

Content-Length 是 Header 其中一个字段,和 HTTP 一样,代表 Body 的长度。

LSP Body

LSP 使用 JSON-RPC 格式描述消息内容,包括请求和响应。简单来说就是一段 utf-8 编码的 JSON 字符串,好处是简单和平台无关。

通信方式

本质上,语言服务器与客户端通信,是进程间通信。常见的方式有 stdio、ipc、pipe、socket 等。VS Code 插件承担了客户端的角色,在官方的 client SDK 中支持了全部这四种通信方式。具体可以见文档这里

生命周期

本质上,LSP 通信就是两个进程之间的通信。一个进程是语言客户端,对应到 VS Code 里,就是插件的进程(Extension Host),然后由它启动语言服务器进程。
接着,两者会初始化,交换一些信息,主要是两端支持哪些能力。换句话说,不同的编辑器,对 LSP 能力的支持是不同的。我们在语言服务器架构就讲过,BetBrains 仅支持部分功能。例如一个语言服务器提供了全量的语义高亮功能,以及(出于性能原因)按行号范围高亮的功能,后者对于成千上万行的用户文本非常重要,但一些编辑器就是不支持后者的,只支持语言服务器提供整个文件范围的高亮。

这里会有一个坑点:协议规定了客户端如果收到服务器发来的,自己不理解的功能,可以忽略它。这本身没有问题,是为了客户端更健壮,至少此时不应该有 exception,但会增加开发者的调试难度。
我碰到的情况是,客户端仅支持 3.16,而服务器使用了 3.17 新增的 Inlay Hints 能力,两端都能非常顺利的启动、运行,但无论如何编辑器中就是不渲染服务器发过来的 hints。原因就是客户端静默处理了自己不认识的 Inlay Hints 能力,服务器费力编译好算出数据发给客户端,客户端直接丢掉不用了。解决方法就是升级客户端代码,让它支持更新的协议版本。

在这之后初始化已完成,就可以交换数据了。客户端会有几个关键的事件,来推进整个通信流程,比如打开、编辑、关闭、删除、新增、重命名文档等等。
虽然初始化已完成,但服务器还有更多事情要做:通常要先从工程范围编译或者预编译(仅编译一个文件中的签名信息,不编译实现)所有代码文件,记录好基础的语义信息、工程结构等(这通常是在内存中,当然从性能角度也可以在磁盘中加一些缓存)。接着,客户端打开一个文件,语言服务器会返回这个文件的高亮诊断悬浮提示信息等等,这样编辑器里就从白纸黑字升级了。接下来,用户按下键盘输入代码,在编辑过程中服务器会提供代码补全签名提示等服务,用户输入完成后,防抖式的重新编译当前文件,更新高亮、诊断等。
随着用户不断编码,语言服务器不断编译,更新语义模型,持续提供语言服务。这也是语言服务器和编译器工作方式的不同之处。

语言服务器是不是可以仅完整编译打开了的文件,而只预编译其他没打开的文件呢?答案是否定的。
有几个高级功能,依赖工程的完整编译:重命名符号、查找引用,这都依赖到完整编译代码实现。另外,在 A 文件有改动后,引起 B 文件的编译错误,这样是常见的情况,这也需要完整编译。

预编译主要解决了循环引用的问题:在 X 代码块中引用了 Y,在 Y 代码块中由又引用了 X,只要仅编译签名,可以绕过编译代码块,理清依赖关系。这又是语言服务器和编译器工作方式的相似之处。

处理用户输入

处理频繁的、无征兆的用户输入是实现语言服务器的一大难点。在用户按键输入的时候,一方面,用户希望立即得到代码补全提示,其中可能包括当前作用域的变量、函数、类等;另一方面,一行代码也许得等到用户输入到最后一个分号 ; 时才是没有语法错误的。前者需要快速响应,后者需要容错,这往往是矛盾的。

为了性能,语言服务器往往通过防抖的方式,在用户输入完一段时间(例如 200ms)后再编译,更新语义模型。这些智能编程功能中,用户对延迟的敏感度是不一样的。想象一下,你在输入一行代码时,高亮更新慢一些、报错信息晚一点出现,似乎都还可以接受。但如果代码补全的列表迟迟不出现,那就非常痛苦了。没有人想一个一个字母的输入一个冗长的变量名对吧,你想手动打出 This_Is_A_Very_Looong_Variable_Name 吗?更多的时候是希望输入 This 之后一个 Tab 就把剩下的 31 个字母都补全了对吧。这是代码补全功能的最大挑战:如何在容错的前提下快速响应用户输入。后边会在具体的功能中详细说明。

另外,为了加速编译,语言服务器可以实现一个高级特性,就是“增量编译”,只更新用户输入改变了的语义模型。可能的实现方式是,如果用户输入只改了一个代码块里的代码,就只更新这一部分的语义模型,而不是完全重新编译。

具体的请求

好了,接下来会聊聊生动有趣的部分,也是最干的部分:具体的请求和我踩过的坑。

初始化 initialize

这是第一个请求,由客户端发到服务器。服务器可以将自身信息(名字、版本号)还有支持的能力发给客户端。也可以声明每个功能的具体细节。

capabilities: {
hoverProvider: true, // 悬浮提示
semanticTokensProvider: {
// 语义高亮
full: false,
range: true,
legend: {
tokenTypes: [],
tokenModifiers: [],
},
},
completionProvider: {
// 代码补全
triggerCharacters: [".", '"', "<", "#"],
},
signatureHelpProvider: {
// 签名提示
triggerCharacters: ["(", ","],
},
definitionProvider: true, // 跳转到定义
inlayHintProvider: true, // 内联提示
},
};

这里注意,这个请求是由客户端发向服务端的。服务端声明的,是由客户端发起的请求的支持情况,不包括服务器主动推给客户端的。
例如,这个请求中无需声明 diagnostic 字段,语言服务器就可以主动推送诊断。而客户端需要输入 "(" "," 来触发 signature help。

打开文件 textDocument/didOpen 和改动文件 textDocument/didChange

两个最基础的文件同步请求,客户端发起,服务端接收。
服务端既能接收到文本内容,也能收到文档的版本号。这个版本号是自增的,随着文件改变而提高,因此可以缓存起来,用于判断文件内容有没有变化。

didChange 请求还可以将(一个或多个)具体改动也告知语言服务器,便于语义信息的增量更新等。比如只是删除了一个函数中的一行,也许就不用重新编译作用域内的其他函数。

代码补全 textDocument/completion

随着用户键盘输入,客户端会向语言服务器发送代码补全请求,获取信息后在光标旁渲染一个补全列表。

VS Code 里下拉框的内容是语言服务器给出的数据的超集。这个下拉框里还包括代码片段(snippet),和 VS Code 自带的补全已有输入的内容。

客户端可能会对列表数据重新排序,例如 VS Code,因此语言服务器也许不需要按字典序返回,省一些性能。

代码补全是可以做的非常深的功能。
最简单的,可以补全所有保留字(keywords),这种基本是稳定的,不会被用户代码影响。难点在于随着用户输入,补全项需要动态更新。

矛盾

var foo = 0
while (true) {
var bar = 1
// 补全 foo、bar,忽略 zee
if true {
var zee = 2
}
}
}

在第 5 行输入时,由于语义要求,应该补全当前作用域以及上层作用域的变量,忽略下层作用域。

这带来一个问题,获取语义信息就意味着需要经过语法分析和语义分析,而你可以想象一下,输入一行代码的过程中,几乎只有最后一个分号写完,这个代码才可能是没有编译错误的。

另外,用户编码时,代码补全如果延迟很高,这会非常痛苦(我想读者们肯定碰到过代码写着写着补全消失的时候),但由于性能原因,语言服务器的更新编译经常是防抖的,也就是在用户停止输入一段时间后再编译,这意味着拿到语义信息有延迟。

这就是代码补全的两个难点:处理容错、以及延迟和性能的矛盾。

这里和具体的语法分析方案相关,我给出一些基于 antlr 做词法分析、语法分析的经验。

首先 antlr 有非常出色的容错能力,即使代码中有语法错误,也可以尽可能解析出正确代码的语法。
这意味着即使正在编辑的这一行代码(和其他位置)有语法错误,其他位置的语义还是可以被正确编译(当然,语言服务器代码中也要有相应的容错,尤其是各种各样的 undefinednil 问题)

但有时候补全往往是和当前这一行相关的,依然需要这一行有错误的代码的语义信息。例如 console. 补全 log warning error 函数。补全项要基于已经输入的代码来异化。

有两个解决的方向,优化语法和从词法尝试推断语法和语义。

优化语法

从语法上,设计更宽容的语法结构,允许一些看上去“没有意义”的代码出现。例如,允许一个属性单独成行,没有读操作也没有写操作。

foo: 1,
bar: {
bar1: true,
bar2: "",
},
};

x.bar; //.bar1

大多数情况下,x.bar 单独成行在运行时是没有什么意义的(除非 bar 是一个 getter 函数)。但对于语言服务器,好处是在最后一行输入 x.bar 后,不会有语法错误,再输入点号 .,语言服务器能根据上次的编译结果(即还没有输入点时)得知左侧是一个对象,进而补全 bar1bar1 两个属性。
但也有副作用,就是语法结构会被污染,不仅仅是出于编译来设计,要考虑更复杂的情况。

从词法推断

还有一个艰难的方法,无法借助词法分析工具,就手动分析残缺的词法,推测可能的语法。

// ^ 光标在此

对于这行代码,必然有语法错误,但 antlr 可以分析出这一行的 token:光标前有一个 DOT,再往前是一个 IDENTIFIER。
就可以尝试分析在当前作用域下,这个 IDENTIFIER 的名字的语义是什么(当然,也有可能是 undefined symbol,那就完全分析失败了),得知是个变量,类型是对象,就一样可以补全其中的属性。

当然,语法结构层出不穷,通过回溯 TOKEN 推测可能的语法结构是一件复杂的事情。

这里推荐这个库 antlr-c3,是专门针对基于 antlr4 的语法分析时,做自动补全的引擎。它会基于语法文件(parser.g4),尝试预测可能的语法节点是什么。

高亮 textDocument/semanticTokens

高亮最好理解了,就是给白底黑字的代码上色。这是一个非常复杂、容易出现性能问题的功能。

VS Code 的高亮

这也是和客户端表现高度相关的功能,先讲讲 VS Code 的高亮系统。一行代码哪里显示蓝色,哪里显示黄色?
首先 VS Code 将文本分段,每一段的渲染方式相同,包括颜色、背景色、字体等,这样的一段被称为一个 token
接着,token 会有类型 type,这一定程度上代表了它的语义,例如关键字、类、枚举等。type 是决定 token 颜色的核心。
VS Code 的颜色主题系统,可以通过 JSON 配置每个 type 的渲染颜色、样式,维护数据表现的映射关系。

"scope": "keyword", // 关键字
"settings": {
"foreground": "#ff007f",
"fontStyle": "bold"
}
}

这样即使用户切换颜色主题,只需要切换样式即可,比如从亮色模式转为深色模式,但无需重新解析 token

除了 type,还有一个 modifier 的概念,它也会影响 token 的渲染,但只是一种修饰,不是必要的。
例如同样是函数,异步函数、静态函数、被弃用的函数、抽象函数,他们的渲染可以有细微的区别,通过 modifier 来实现,不过这就取决于具体的颜色设计了。

语法高亮

脱离 LSP,VS Code 有一个轻量的无需编程的、基于正则的高亮系统,与语义无关,被称为语法高亮

刚才说到 VS Code 的高亮首先要将文本分段为 token,这其实也是一个词法分析的过程(Tokenization)。方式就是正则表达式,通过正则匹配,基于词法和语法,做简单的高亮,具体的配置规则被称为 tmLanguage 或者 Textmate grammars
比如注释、关键词、操作符、字面量(数字、布尔、字符串等),就很适合由此高亮。

TypeScript 就有一个规模惊人的配置文件。这种文件实在是人类太不可读了,我的建议是要灵活借助 AI 的力量,让 LLM 根据需求生成配置还是比较顺利的。

几个坑点是,tmLanguage.json 采用严格的 json 语法,不能有注释,否则,配置的任何高亮在 VS Code 中都不会生效。想存注释的话,可以手写一个自定义字段来实现。在调试时建议使用官方的检查器,来确认匹配结果。

如果配置文件格式正确,检查器也显示成功匹配(token type 符合预期),但依然没有高亮,那么可以切换颜色主题试试,可能是因为当前颜色主题的色彩太少了,很多 token 就是默认的颜色。

这就低成本实现了简单的高亮。

在2025年的1月版本中(1.97),VS Code 官方开始尝试用 Tree-Sitter 代替 tmLanguage,用词法、语法分析替换单纯的正则匹配。理由是许多 tmLanguage 都不再维护了。

语义高亮

有了 VS Code 的例子,理解 LSP 中的高亮就不困难了。客户端会在某些时机向语言服务器请求高亮数据,服务器返回一个个 token,包括位置、typemodifier 等,客户端自行决定如何渲染。

这是客户端主动发起的请求,在 initialize 请求中,需要一些配置,例如下边这种。一点一点解释:

full: {
delta: true,
},
range: true,
legend: {
tokenTypes: ["method","property","string", ...],
tokenModifiers: ["readonly","async","static", ...],
},
}

这个配置有点复杂,如果配的有问题,客户端的高亮会无静默失效,服务器似乎不会收到报错。一行行解释:

所谓的 legend,是用来声明 token 有哪些 typemodifier。他们实际上是两个字符串数组,例如 type 可能是 ["method","property","string", ...]
需要事先声明,是因为字符串用于通信太冗余了,需要做一些压缩:
想象一万行的文件,有多少个 token?如果完整的渲染每一行,浪费不说(用户一次只会看一些行),性能也有巨大的压力。
另外 token 是一个有序的列表,在中间行改动就意味着它后边的所有 token 都得变化,在算法上也有挑战。

为此 LSP 为数据通信做了简单的编码来降低通信的流量。具体的编码方式可以看官方的文档,简单来说就是将位置、typemodifier编码为 5 个整数,通信时不再用明文的 "method" "property" 等。

另外 LSP 还提出两个优化,增量更新(full/delta)和范围渲染(range)。
增量更新是指针对大量 token 时,只在首次请求 textDocument/semanticTokens/full 中返回全量的 token,之后发送 textDocument/semanticTokens/full/delta 请求,语言服务器需要根据文件变化,计算出比起上一次返回的 token 有哪些差异,返回增量部分。
范围渲染就简单了,客户端会根据用户能看到的文本范围,只请求部分范围的 token。请求是 textDocument/semanticTokens/range。因此,语言服务器最好按照文本字符流的顺序收集 token,这样每次请求无需遍历所有的 token,到范围外就可以截断了。

此外,这个请求由客户端发起,这意味着服务端不可控。打开文件、滚动屏幕、调整窗口大小,都会引起这个请求,这个相对好处理。
困难在于用户输入引起的请求。这会引起编译,而高亮需要编译好的语义信息,这就遇到了和签名提示还有代码补全类似的困境:请求时序、防抖更新、等待编译完成等问题。

Vue 的语言服务器使用了简单粗暴,但是非常有效的方式:仅支持范围高亮,并且收到请求后固定等待一段时间,比如 200ms 后再响应。

内联提示 textDocument/inlayHint

这是用来快速浏览信息的功能,通常会用来显示函数定义时形式参数的名字等。否则这个信息需要悬浮提示或者跳转到定义才能拿到,不过也有人会觉得这个提示太干扰,所以最好做成用户可配置关闭的。
这是一个比较新的请求,是 LSP 3.17 新增的。要注意客户端和服务端支持的协议版本都要大于等于 3.17,否则可能会静默失败,没有报错。

签名提示 textDocument/signatureHelp

写函数调用时,输入左括号 ( 时弹出的信息,这就是签名提示。这时候用户会想看到函数的签名信息,包括形式参数的名字、类型,甚至函数返回值等。

时序问题

麻烦在于输入左括号 ( 时还会有一个请求,也就是文件改动 textDocument/didChange,引起防抖处理和重新编译。通常要等编译完成,才能正确的知道具体是针对哪个函数,来获取其签名信息。
很重要的一点是明确 didChangesignatureHelp 的时序问题,因为由用户输入触发的签名提示的相应,依赖编译完成,使用最新的语义信息(旧的状态是没有意义的)。而请求都是由客户端发出的,具体哪个请求会先发出呢?
目前 LSP 协议(3.17)中似乎没有显式的规定这一点,通过咨询官方,结论是用户键入后,客户端应该确保 didChange 先发送到服务器,然后再请求 signatureHelp,也就是说服务器处理 signatureHelp 请求时一定能获取到最新的客户端状态,以及最新的语义信息。

方向键

用户输入后,一定要等防抖以及编译后才能响应,这意味着一定的延迟。但也有一种非常轻量的情况,按方向键也可以触发 signatureHelp,比如光标扫过一个个实际参数时,提示的形式参数的高亮也要改变。

这时候就不用等编译了,因为输入没有改变,只需要根据光标位于哪一个实际参数,高亮形式参数。
不过 didChangesignatureHelp 是两个独立的请求,语言服务器怎么知道一次 signatureHelp 请求是由方向键响应的呢?

还记得前面说 didChange 请求会提供文本的版本号吗,它可以作为输入是否改变的依据。记录每一次 didChange 的版本号,如果两次 signatureHelp 请求之间版本号没有变化(变大),那么输入就没有变化。

代码补全时触发

代码补全时,如果用户选择(resolve)了一个函数,一些语言的服务器会将调括号 () 一起补全,例如 Go。

最好的体验是补全后立即触发 signature help,因为这个时候用户就是想要填实际参数,想知道参数数量和类型。
VS Code 有个指令是 triggerParameterHints,Go 的插件确实是这么做的

Color

如果语法简单,非常适合在前一章提到的语法的(而非语义的)服务器上解析。例如仅支持井号 # 加十六进制这种语法。

但 css 中那种支持 rgba、十六进制甚至直接颜色名称 red 的语法,就不适合了。

诊断 textDocument/publishDiagnostics

这是指把编译错误和警告显示在编辑器里的功能。在 VS Code 里,主要表现为红色、橙色的波浪线。
按理来说,语言服务器的诊断应该和编译器的编译错误的表现一致。当然,除了语言服务器,可能还有别的工具(比如定制化的 lint 等)也在输出诊断,这会导致编辑器里看到的诊断比编译器输出的多。

除了波浪线,诊断还有两种额外的表现 UnnecessaryDeprecated,标记没有引用到的字段,和弃用的字段。

/**
* Unused or unnecessary code.
*
* Clients are allowed to render diagnostics with this tag faded out
* instead of having an error squiggle.
*/
export const Unnecessary: 1 = 1;
/**
* Deprecated or obsolete code.
*
* Clients are allowed to rendered diagnostics with this tag strike through.
*/
export const Deprecated: 2 = 2;
}

在 VS Code 里会表现为颜色变浅和删除线。

如果代码里有个字段暂时没法删掉,但又不想有新代码用到,就可以标为废弃,这样同事写出这行代码就会出现删除线,吓他一跳。

诊断还是高亮?

UnnecessaryDeprecated 的表现其实很像是高亮的行为,影响了代码渲染。
而高亮有个修饰符(modifier)也同样是 deprecated

readonly = "readonly",
static = "static",
deprecated = "deprecated",
//...
}

实际上 VS Code 中很多删除线都是由诊断而不是高亮实现的。
官方说法是,语言服务器不会具体规定客户端的表现,而是由客户端自行决定渲染。对 VS Code 来说,DiagnosticTagSemanticTokenModifiers 出现的更早,因此客户端支持更好。

LSP 与 VS Code 插件开发(三)语言服务器协议 | imbAnt’s blog