关于我
技术随笔

浅析 vscode 代码高亮实现原理

从词法高亮到编程式语言扩展,拆解 VSCode 实现代码高亮与语言特性的三条路径——基于 TextMate 语法的词法高亮、基于 Semantic Tokens / Language API 的编程式扩展,以及基于 Language Server Protocol 的多进程架构,并解释它们各自的适用边界。

VSCode 是这几年最流行的代码编辑器之一,功能强大、生态繁荣,其中一个很基础但也很重要的能力就是代码高亮:不同类型的 token(关键字、变量、字符串、注释……)以不同颜色呈现,帮助阅读者快速建立代码结构的视觉映射。

代码高亮看起来简单,背后的实现却分好几个层次。VSCode 提供了多种扩展语言能力的方式,代码高亮只是其中最基础的一环。本文从最简单的词法高亮讲起,逐步深入到编程式的语言扩展,最后过渡到基于 Language Server Protocol 的多进程架构,把 VSCode 支持一门语言的完整能力谱系串起来。

先看一段最终效果,同一份代码在没有语法插件和有语法插件时的对比:

1. VSCode 插件基础

VSCode 本身只是一个通用的文本编辑器,对具体某门语言的理解能力,都是通过插件扩展进来的。围绕语言支持,VSCode 大致提供了五类扩展能力:

  • 词法高亮:基于 TextMate 语法规则,对 token 做正则匹配并着色,是最基础、成本最低的一种
  • Semantic Tokens Provider:以编程方式补充词法高亮无法表达的语义信息(例如区分一个标识符到底是变量还是函数)
  • Language API:VSCode 内置的一组语言特性接口,可以编程式地提供代码补全、悬停提示、定义跳转等能力
  • Language Server Protocol:把语言能力从 VSCode 进程里剥离出来,以独立进程 + 标准协议的方式提供,跨编辑器复用
  • 其它辅助能力(代码片段、语言配置等)

这五类能力从上到下,实现成本递增、表达能力也递增。本文按这个顺序展开,先讲词法高亮,再讲编程式扩展。

2. 词法高亮

词法高亮是最基础的一层,本质上就是分词 + 着色两步:先把源代码切分成一个个有类型的 token,再根据 token 类型套用主题里定义的颜色。

VSCode 的词法高亮基于 TextMate 语法。TextMate 是一款 macOS 上的老牌编辑器,它定义的语法文件格式后来被大量编辑器沿用,成为事实标准。一份 TextMate 语法文件本质上是一组正则规则,描述"什么样的字符串该被识别成什么类型的 token"。

2.1 分词的基本规则

最简单的语法规则长这样:

{
  "scopeName": "source.json5",
  "patterns": [
    {
      "match": "\\b(true|false|null)\\b",
      "name": "constant.language.json5"
    }
  ]
}

patterns 是一组匹配规则,每条规则包含两个核心字段:match 是一个正则表达式,用于匹配源代码中的字符串;name 是命中后赋予该 token 的类型名(TextMate 里称为 scope)。上例的含义是:把 truefalsenull 三个词识别为 constant.language.json5 类型。

scope 是一个以 . 分隔的层级字符串,命名有约定俗成的规范,例如 constant.languagestring.quoted.doublekeyword.control 等。主题文件正是通过匹配这些 scope 来决定着色的。

vscode-json5 插件为例,它为 JSON5 格式提供了完整的语法定义,效果如下:

2.2 复合分词

单条 match 规则只能匹配一段连续的字符串,遇到有起止边界的结构(例如字符串、注释、括号块)就不够用了。这时需要用 begin / end 规则:

{
  "name": "string.quoted.double.json5",
  "begin": "\"",
  "end": "\"",
  "patterns": [
    {
      "name": "constant.character.escape.json5",
      "match": "\\\\."
    }
  ]
}

begin 匹配结构的起始(这里是起始引号),end 匹配结构的结束(结束引号),两者之间的所有内容都归属于 name 指定的 scope。这样就能把一整个字符串识别成 string.quoted.double.json5

2.3 规则嵌套

begin / end 规则内部还可以再嵌套 patterns,用于识别结构内部的子 token。上例里字符串内部又嵌了一条规则,用于把转义字符 \\. 识别成 constant.character.escape.json5——也就是说,字符串整体是一个 scope,字符串里的转义序列又是另一个更细的 scope。

通过 matchbegin/endpatterns 嵌套这三种手段的组合,就能描述出相当复杂的语言词法结构。

2.4 样式定义

分词只是把 token 打上了 scope 标签,具体着成什么颜色,是由主题(Theme)文件决定的。主题文件本质上是一张 scope → 颜色的映射表:

{
  "tokenColors": [
    {
      "scope": "constant.language",
      "settings": {
        "foreground": "#569CD6"
      }
    },
    {
      "scope": "string.quoted",
      "settings": {
        "foreground": "#CE9178"
      }
    }
  ]
}

这套设计的巧妙之处在于语法与主题解耦:语法插件只负责分词、给 token 打 scope 标签,主题插件只负责给 scope 上色。同一份语法文件配不同主题,就能呈现出不同的配色方案;反之,一套主题也能作用于所有遵循 scope 命名约定的语言。

2.5 实例解析

理解了规则之后,我们看一个稍微完整的例子。假设要给 json5 的键值对着色,可以这样写:

上面这段规则先用 begin/end 圈定对象的 { } 范围,再在内部用规则分别匹配 key、冒号、value,逐层拆解出对象的结构。

2.6 调试工具

编写 TextMate 语法时,最常见的问题是"这段代码为什么没被正确高亮"。VSCode 内置了一个非常好用的调试工具:命令面板执行 Developer: Inspect Editor Tokens and Scopes,然后把光标停在任意 token 上,就能看到该位置被识别成的完整 scope 链,以及命中的主题规则:

这个工具能直接告诉你某个 token 当前的 scope 是什么、被哪条主题规则着的色,是调试语法与主题问题的第一选择。

3. 编程式语言扩展

词法高亮基于正则,本质上是无状态的模式匹配,它不理解代码的语义。举个例子,正则没法区分一个标识符到底是一个变量名还是一个函数名——因为这需要结合上下文做语义分析,而正则做不到。要表达这类语义信息,就得引入编程式的扩展能力。

3.1 DocumentSemanticTokensProvider

DocumentSemanticTokensProvider 是 VSCode 提供的一个语义着色接口。它允许插件用代码的方式,为文档里的每个 token 计算并返回语义类型(变量、函数、类、参数……),VSCode 再据此叠加一层语义着色,弥补词法高亮无法表达语义的短板。

它的核心是实现一个 provideDocumentSemanticTokens 方法,返回一组编码后的 token 信息。每个 token 用五个整数描述:相对上一个 token 的行偏移、列偏移、token 长度、token 类型、token 修饰符。之所以用这种紧凑的相对编码,是为了在大文件下也能高效传输大量 token 数据:

语义分析的过程通常是:先把源码解析成 AST(抽象语法树),再遍历 AST,根据每个节点的语义角色计算出对应的 token 类型,最后编码成上述格式返回。运行效果:

DocumentSemanticTokensProvider 补齐了词法高亮做不到的语义着色,但它仍然运行在 VSCode 主进程内,能力也局限于"着色"这一件事。要提供更丰富的语言特性(补全、悬停、跳转……),需要用到范围更广的 Language API。

3.2 Language API

VSCode 通过 vscode 模块暴露了一整套 Language API,覆盖了绝大多数常见语言特性:代码补全(registerCompletionItemProvider)、悬停提示(registerHoverProvider)、定义跳转(registerDefinitionProvider)、函数签名(registerSignatureHelpProvider)等等。每个特性都是"注册一个 provider,在回调里返回符合约定的结果"这个套路。

以悬停提示为例,注册一个 HoverProvider

vscode.languages.registerHoverProvider('javascript', {
  provideHover(document, position, token) {
    const range = document.getWordRangeAtPosition(position);
    const word = document.getText(range);
    return new vscode.Hover(`当前单词:${word}`);
  }
});

当用户把鼠标停在某个 token 上时,VSCode 会调用 provideHover,把当前文档、光标位置传进来,插件计算出要展示的内容返回即可:

其它语言特性的实现方式高度一致,都是注册 provider + 在回调里返回结果,可以举一反三。查阅特性的接口与参数结构,可以直接参考 VSCode 官方 API 文档

3.3 Language Server Protocol

Language API 已经足够强大,但它有两个绕不开的硬伤:一是插件必须用 JavaScript/TypeScript 编写,跑在 VSCode 进程里,一门语言的分析逻辑没法用其它语言实现,也没法复用到别的编辑器;二是复杂的语言分析会和 UI 抢占同一个进程,影响编辑器响应。

Language Server Protocol(LSP)就是为了解决这两个问题设计的。它把语言能力拆成两半:Language Client 跑在编辑器进程里,负责收集用户行为并转发;Language Server 跑在独立进程里,负责实际的语言分析和计算。两者之间用一套标准化的、基于 JSON-RPC 的协议通讯:

这样一来,同一个 Language Server 可以被 VSCode、Vim、Sublime 等任意支持 LSP 的编辑器复用,Server 也可以用任意语言(Go、Rust、Python……)实现,还天然把繁重的分析工作隔离到了独立进程,不阻塞 UI。整体架构如下:

要接入 LSP,需要在 package.json 里声明插件的激活条件和入口,然后分别实现 Client 与 Server 两端。Client 端主要负责配置 Server 的启动方式与通讯参数:

const serverOptions: ServerOptions = {
  run: { module: serverModule, transport: TransportKind.ipc },
  debug: { module: serverModule, transport: TransportKind.ipc }
};

const clientOptions: LanguageClientOptions = {
  documentSelector: [{ scheme: 'file', language: 'plaintext' }]
};

const client = new LanguageClient(
  'languageServerExample',
  'Language Server Example',
  serverOptions,
  clientOptions
);
client.start();

Server 端则是一个标准的 Language Server:初始化连接、声明支持的能力、监听各类事件并返回结果。一个最小的 Server 骨架:

const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);

connection.onInitialize((params: InitializeParams) => {
  return {
    capabilities: {
      hoverProvider: true
    }
  };
});

connection.onHover((params) => {
  return { contents: ['Hover from Language Server'] };
});

documents.listen(connection);
connection.listen();

运行效果与前面的 Language API 版本一致,区别只在于计算逻辑跑在了独立进程里:

LSP 的实现细节与更多语言特性示例,可以参考 LSP 官方文档vscode-extension-samples

4. 总结

回过头看,VSCode 支持一门语言的能力,实际上是一条从简单到复杂、从静态到动态的谱系:

  • 词法高亮基于 TextMate 语法规则,用正则做无状态的分词与着色,成本最低,但只能识别模式、无法理解语义
  • Semantic Tokens ProviderLanguage API 引入了编程式的语义分析,能表达词法高亮做不到的语义信息,也能提供补全、悬停、跳转等丰富特性,代价是必须用 JS/TS 编写并运行在 VSCode 进程内
  • Language Server Protocol 进一步把语言能力拆成独立进程 + 标准协议,换来了跨编辑器复用、跨语言实现、进程隔离这三重收益,是目前主流语言插件的实现方式

选择哪一层,取决于你要提供的能力有多复杂:只需要着色,词法高亮足矣;需要语义特性但只服务 VSCode,用 Language API 更轻量;要做一门语言的完整、可复用的支持,才有必要投入 LSP 这套相对更重的架构。

On this page