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.jsScope 是继承嵌套的。编辑器匹配时会从最具体的 scope 向上找父级,直到找到主题里有定义的那个。所以:
- 写语法时尽量复用已有的标准 scope(
keyword、string、comment、variable) - 自造 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 / end 和 begin / 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 / end 或 begin / while 来表示,而不是指望一条正则直接吞掉多行内容。在正则网站上测试通过的表达式,放进 grammar 里不一定成立,因为两边的匹配模型并不一样。
范围规则不会自动“修正”错误起点
一旦 begin 匹配成功,接下来这个范围会一直持续到 end 命中,或者直接到文件末尾。如果 end 写得太宽松或根本匹配不到,scope 就会一路“泄漏”到后面的大段文本。写 begin / end 时,结束条件是否可靠,往往比开始条件更重要。
错误正则通常表现为“规则不生效”
正则写错之后,最常见的现象不是报出一条清晰错误,而是某条规则始终匹配不到,或者 scope 链看起来完全不对。调试时最好同时用 Scope Inspector 和最小测试文件,不要只靠肉眼猜。
真正的嵌套要放在 patterns,不是 captures
新手很容易把 captures 和 patterns 混在一起。captures、beginCaptures、endCaptures 只能给捕获组打 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 机制。写好语法时,最值得注意的是:
- 复用标准 scope,不要自己发明
- 注意正则的单行限制,跨行需求要换思路
- 范围规则最怕 scope 泄漏,确保
end或while条件可靠 - 用 Scope Inspector 调试,不要靠猜
- 区分 grammar token 和 semantic token,它们解决的问题不是一层
参考资料:Writing a TextMate Grammar: Some Lessons Learned / Syntax Highlight Guide | VSCode API