TextMate 语法高亮

什么是 TextMate

VSCode 的语法高亮底层使用 TextMate grammar。即使你再接入 Language Server,TextMate 语法仍然是基础层:Semantic Token 是叠加在 grammar token 之上的,不是简单替代关系。

TextMate grammar 的特点是:它基于单文件输入和正则规则做词法级 tokenization,编辑器可以很快给出基础高亮。相比之下,Semantic Token 依赖语言服务的符号理解和项目上下文,通常会晚于基础高亮到达。

这也是为什么很多语言扩展会同时提供这两层能力:TextMate grammar 负责基础语法着色,Semantic Token 再补充更精细的语义信息。

Scope:别造轮子

Scope 是 TextMate 语法的核心抽象,本质上是一个点分隔的字符串,用来描述当前 token 的上下文。

keyword.operator.arithmetic.js

Scope 是继承嵌套的。编辑器匹配时会从最具体的 scope 向上找父级,直到找到主题里有定义的那个。所以:

  • 写语法时尽量复用已有的标准 scope(keywordstringcommentvariable
  • 自造 scope 名称如果没有主题支持,就等于没有高亮

VSCode 自带 Scope Inspector(Developer: Inspect Editor Tokens and Scopes),写语法时打开它可以看到当前 token 的完整 scope 链,是最重要的调试工具。

常见规则形式

TextMate grammar 里的 pattern 常见有三种写法,理解它们的区别是写好语法的前提。

单规则(One-Pattern)

用一个 match 正则匹配一段文本:

{
  "name": "constant.character.escape.markdown",
  "match": "\\\\[-`*_#+.!(){}\\[\\]\\\\>]"
}

name 决定这段匹配被赋予哪个 scope。match 规则还可以配合 captures,给正则分组单独指定 scope;但它本身不负责继续向内部做嵌套解析。需要嵌套时,通常要改成 begin / end 这类范围规则。

双规则(Two-Pattern)

begin + end(或 begin + while)定义一个范围,适合字符串、注释、代码块这类有明确边界的内容:

{
  "name": "string.quoted.double.js",
  "begin": "\"",
  "end": "\"",
  "patterns": [{ "include": "#escape" }]
}

begin / endbegin / while 都是范围规则,但语义不一样:前者在匹配到 end 时结束范围;后者则是在每一行继续检查 while 条件,只要条件成立就让范围延续下去。写注释块、缩进块或 heredoc 一类结构时,这个区别很关键。

包含规则(Include)

include 把仓库里的规则或外部 grammar 引用进来:

{ "include": "#expression" }
{ "include": "text.html.basic" }

通过 include 把复杂语法拆成小模块,是维持可维护性的关键。

语法结构:repository 是精髓

一个 TextMate 语法文件的顶层结构很直白:

{
  "scopeName": "source.js",
  "patterns": [{ "include": "#expression" }],
  "repository": {
    "expression": {
      "patterns": [
        { "include": "#comment" },
        { "include": "#string" }
      ]
    }
  }
}

patterns 是规则列表,顺序很重要。TextMate 会按顺序尝试这些规则,优先采用先命中的那一条;repository 里的规则则可以被 include 引用,用来做拆分和复用。

这几个坑文档里不会明说

单条正则一般不能跨行

TextMate grammar 的单条正则通常只针对单行文本工作,这也是为什么多行结构要靠 begin / endbegin / while 来表示,而不是指望一条正则直接吞掉多行内容。在正则网站上测试通过的表达式,放进 grammar 里不一定成立,因为两边的匹配模型并不一样。

范围规则不会自动“修正”错误起点

一旦 begin 匹配成功,接下来这个范围会一直持续到 end 命中,或者直接到文件末尾。如果 end 写得太宽松或根本匹配不到,scope 就会一路“泄漏”到后面的大段文本。写 begin / end 时,结束条件是否可靠,往往比开始条件更重要。

错误正则通常表现为“规则不生效”

正则写错之后,最常见的现象不是报出一条清晰错误,而是某条规则始终匹配不到,或者 scope 链看起来完全不对。调试时最好同时用 Scope Inspector 和最小测试文件,不要只靠肉眼猜。

真正的嵌套要放在 patterns,不是 captures

新手很容易把 capturespatterns 混在一起。capturesbeginCapturesendCaptures 只能给捕获组打 scope;真正的嵌套匹配,是写在 begin / end 规则里的 patterns 里。也就是说,patterns 恰恰就是范围规则内部继续分层解析的主要手段。

VSCode 的扩展:Injection Grammar

VSCode 支持 Injection Grammar,也就是向已有语言注入新的语法规则,而不需要派生完整 grammar。

典型用法:在 JavaScript 的注释里高亮 TODO 关键字。

package.json 里声明注入目标:

{
  "contributes": {
    "grammars": [{
      "scopeName": "todo-comment.injection",
      "path": "./syntaxes/injection.json",
      "injectTo": ["source.js"]
    }]
  }
}

grammar 文件本身用 injectionSelector 精确定位注入点:

{
  "scopeName": "todo-comment.injection",
  "injectionSelector": "L:comment.line.double-slash",
  "patterns": [{ "include": "#todo-keyword" }]
}

L: 前缀表示把注入规则放到现有规则的左侧,也就是让这些注入规则优先参与匹配。

Injection Grammar 适合:TODO 高亮、Markdown 代码块的语言高亮、模板字符串里的 SQL 高亮等场景——不需要改原语言 grammar,只要追加规则。

Semantic Token

VSCode 1.43 之后支持 Semantic Token Provider,由 Language Server 提供更深层的语义信息。比如一个变量在整个项目里都被声明为常量,Semantic Token 能让它在整个文件里都显示成常量色,而不是只在声明处。

Semantic Token 依赖语言服务提供信息,因此通常会晚于基础 grammar token 生效。实际效果往往是:文件一打开先看到 TextMate 提供的基础高亮,随后再叠加更细的语义着色。

小结

TextMate grammar 仍然是很多编辑器和扩展生态里的重要基础。它的核心是 scope 继承体系、范围规则和 include 机制。写好语法时,最值得注意的是:

  1. 复用标准 scope,不要自己发明
  2. 注意正则的单行限制,跨行需求要换思路
  3. 范围规则最怕 scope 泄漏,确保 endwhile 条件可靠
  4. 用 Scope Inspector 调试,不要靠猜
  5. 区分 grammar token 和 semantic token,它们解决的问题不是一层

参考资料:Writing a TextMate Grammar: Some Lessons Learned / Syntax Highlight Guide | VSCode API