从 TextMate Grammar 到 Semantic Token:VSCode 语法高亮到底分几层

很多人第一次做 VSCode 语言扩展时,会把“高亮”当成一个功能来理解。结果一查文档,发现同时有 TextMate grammar、Semantic Token、主题、Language Server,看起来都像在给代码上色。

其实 VSCode 的高亮由几层机制叠在一起。它们解决的问题不同,出现的时机也不同。如果不先把这一点想清楚,扩展很容易做到一半就陷入混乱:该写 grammar 的地方上了 LSP,该交给语义层的事情又硬塞进正则。

第一层:TextMate Grammar

最先起作用的是 TextMate grammar。编辑器一打开文件,就会根据 grammar 里的规则把文本切成一串 token,并给这些 token 赋上 scope,比如:

keyword.control
string.quoted.double
comment.line
entity.name.function

这一层的特点很明确:

  • 它是基于文本模式工作的,不理解类型系统、作用域和符号表
  • 它响应很快,因为只依赖当前文件内容
  • 它负责的是“基础语法着色”,并不承担完整语义理解

所以,像关键字、字符串、注释、数字字面量、简单的函数定义头,这些都适合交给 TextMate grammar。

第二层:主题和 Scope 映射

TextMate grammar 产出的 scope,本身还不是颜色。真正决定“这个 token 显示成什么样”的,是当前主题如何映射这些 scope。

例如同样是:

keyword.control

在不同主题下,可能会显示成不同颜色、不同字重,甚至不同字形风格。

这也是为什么 grammar 设计时应该尽量复用已有 scope 名称。你发明一个没人认识的新 scope,主题往往就不会给它任何特殊样式,最终看起来等于没高亮。

第三层:Semantic Token

Semantic Token 是更晚到达的一层。它通常由 Language Server 或扩展里的语义分析器提供,不再只看文本模式,而是基于语法树、符号表、类型信息甚至整个项目上下文来给 token 分类。

它解决的是 TextMate grammar 做不到的问题,比如:

  • 同样一个标识符,到底是变量、参数、字段还是类型名
  • 某个名字在这里是函数调用,还是类型构造
  • 一个变量是不是 readonlydefaultLibrarydeprecated

举个最常见的例子:

const Point = 1;
function f(Point: number) {
  return Point;
}

对 TextMate grammar 来说,这几个 Point 很可能只是“看起来像标识符”。但对 Semantic Token 来说,它们处在不同位置,语义角色也不同。

为什么两层都要有

因为它们覆盖的是两个不同阶段。

TextMate grammar 的优势是快。文件一打开,基础高亮立刻就能出现。Semantic Token 的优势是准。等语言服务完成分析之后,它再把更细的语义信息叠上去。

实际效果通常是这样的:

  1. 编辑器先用 TextMate grammar 给出基础配色
  2. 语言服务启动并分析当前文件
  3. Semantic Token 返回后,覆盖或补充更细的着色结果

因此在 VSCode 里,TextMate grammar 和 Semantic Token 更接近前后叠加关系,不是简单替代关系。

哪些问题该交给哪一层

一个简单的判断标准是:只靠当前文本表面形式能不能稳定判断

如果能,优先放在 grammar:

  • 关键字
  • 注释
  • 字符串
  • 数字字面量
  • 明确边界的内嵌片段

如果不能,就该放在 semantic token:

  • 变量、参数、字段、类型的区分
  • 同名符号在不同作用域里的角色变化
  • 废弃符号、内建符号、只读符号等修饰信息

一个常见误区是试图用 TextMate grammar 区分“函数调用”和“普通变量引用”。只要语言稍微复杂一点,这件事就会立刻失控,因为它需要的已经超出正则范围,进入语义分析了。

覆盖关系是怎么工作的

VSCode 的最终着色结果,通常是 grammar token 和 semantic token 共同作用的结果。粗略理解可以是:

  • TextMate grammar 先提供基础 token 和 scope
  • 主题根据 scope 给出默认样式
  • Semantic Token 再根据语义类型提供更精细的分类
  • 主题如果配置了 semantic token 规则,就会用它们进一步覆盖显示效果

所以你看到的“最后颜色”,未必只来自 grammar,也未必只来自 semantic token,更多是两套系统和主题共同叠加出来的结果。

对扩展作者意味着什么

如果你在做一门语言的 VSCode 支持,这种分层关系意味着:TextMate grammar 适合先承担基础高亮,语言服务再补上更细的语义能力。

等 grammar 稳定以后,再逐步补上 Semantic Token,就能把 grammar 做不到、但用户肉眼很在意的区分补齐。

反过来说,如果把所有着色问题都交给 Semantic Token,那么语言服务尚未返回结果时,编辑器在基础可读性上就会留下明显空档。

小结

VSCode 的语法高亮至少可以拆成两层:TextMate grammar 负责快速的文本级 tokenization,Semantic Token 负责更晚到达的语义级分类。两者之间还夹着主题对 scope 和 token type 的映射规则。

把这几层拆开之后,很多扩展开发里的问题就会清楚很多:什么应该写进 grammar,什么必须交给语言服务,什么只是主题表现,本身并不属于解析能力。


参考资料:Syntax Highlight Guide | VSCode API / Semantic Highlight Guide | VSCode API