如果你经常使用 Neovim、Zed 或 Helix 这样的编辑器,大概已经接触过 Tree-sitter。现在很多编辑器都使用 Tree-sitter 提供语法高亮、代码折叠、结构化选择,以及嵌入式语言支持。
对于 Rust、Go、Python 等主流语言,我们通常只需要安装现成的 Parser。但如果遇到一门比较小众的语言,或者某种特殊的配置文件或 DSL 格式,还没有 Tree-sitter 支持,该怎么办?
实际上,为一门简单语言编写 Tree-sitter Parser,并没有想象中复杂。
我之前写过几个 Tree-sitter Grammar:
- Vector Remap Language(VRL)
- tree-sitter-edoc
- Prometheus Text Exposition Format
- OpenMetrics Text Exposition Format
VRL 是 Vector 使用的一门数据转换语言,具有变量、表达式、函数调用、条件判断、闭包等特性,语法相对复杂。 Prometheus 的文本指标格式则简单得多,基本上是按行组织的结构化文本。
本文主要使用 tree-sitter-prom 作为例子,从零介绍一个 Tree-sitter Parser 的开发过程,包括:
- Tree-sitter 的基本原理与 Parser 结构
- 使用
grammar.js定义语法 - 生成 Parser 并编写测试
- 使用
highlights.scm实现语法高亮 - 使用
injections.scm实现嵌入式语言解析 - 在 Neovim 和 Zed 中集成自己的 Parser
这里不会深入讲解 LR Parsing 算法,而是关注如何实际完成一个可用的 Tree-sitter Parser。
Tree-sitter 是什么?
Tree-sitter 是一个增量式语法解析器生成工具,同时也是一个运行时 Parsing Library。
它与传统编译器使用的 Parser 有一些不同。
编译器中的 Parser 通常希望输入的代码是完整且符合语法的,而编辑器中的源代码几乎随时处于不完整状态。比如你刚输入:
fn hello(
一个传统 Parser 可能会立即报告语法错误,但 Tree-sitter 仍然可以生成一棵包含错误或缺失节点的语法树。
Tree-sitter 有几个适合编辑器场景的特性:
增量解析(Incremental Parsing)
编辑代码时,不需要每次都重新解析整个文件。Tree-sitter 可以利用之前的语法树,重用没有受到修改影响的部分。
错误恢复(Error Recovery)
即使代码存在语法错误,也尽可能生成可用的语法树,让编辑器继续提供高亮等功能。
具体语法树(Concrete Syntax Tree)
与编译器常见的 AST 不同,Tree-sitter 生成的语法树会保留更多源代码的语法结构,包括括号、分隔符和其他匿名 Token。
Query 系统
Tree-sitter 使用一种类似 Scheme (一种 Lisp 方言) 的 Query 语言匹配语法树节点。
编辑器可以使用同一棵语法树实现很多功能,例如:
- Syntax Highlighting
- Code Folding
- Language Injection
- Text Objects
- Indentation
- Code Navigation
因此,Tree-sitter 本身并不负责决定某个 Keyword 应该显示什么颜色,也不负责实现代码补全。
它主要负责将源代码解析成一棵具有明确结构的语法树。
Tree-sitter Parser 的整体结构
一个 Tree-sitter 项目通常包含以下文件:
tree-sitter-example/
├── grammar.js
├── tree-sitter.json
├── package.json
├── queries/
│ ├── highlights.scm
│ ├── injections.scm
│ ├── locals.scm
│ └── folds.scm
├── test/
│ └── corpus/
│ └── basic.txt
├── src/
│ ├── parser.c
│ ├── grammar.json
│ └── node-types.json
└── bindings/
这些文件各有不同的作用。
| 文件 | 作用 |
|---|---|
grammar.js | 定义语言的语法规则 |
tree-sitter.json | Grammar 元数据和相关配置 |
src/parser.c | 由 Tree-sitter 生成的 C Parser |
src/node-types.json | 描述语法树中的命名节点及其结构 |
queries/highlights.scm | 语法高亮规则 |
queries/injections.scm | 嵌入式语言解析规则 |
queries/locals.scm | 变量定义、引用和作用域信息 |
queries/folds.scm | 代码折叠规则 |
test/corpus/ | Parser 测试用例 |
其中,最重要的是 grammar.js 和 queries/。
需要注意,grammar.js 定义的是语言本身的语法,而 queries/ 中的文件定义的是如何使用生成的语法树。
两者的关系大致如下:
Source Code
│
▼
Tree-sitter Parser
(grammar.js → parser.c)
│
▼
Concrete Syntax Tree
│
├── highlights.scm
│ └── Syntax Highlighting
│
├── injections.scm
│ └── Embedded Languages
│
├── locals.scm
│ └── Local Variables & Scopes
│
└── folds.scm
└── Code Folding
这里的 Query 文件并不是全部都必须提供。一个简单的语言,可以先实现 grammar.js 和 highlights.scm,其他功能按需增加。
另外,不同编辑器对 Query 文件的支持和 Capture 命名不完全一致。这一点在后面的集成部分会再次提到。
从 Prometheus 文本格式开始
Prometheus 的 Exporter 通常通过 HTTP /metrics 接口暴露指标。
请求这个接口,可能得到类似下面的文本:
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 1024
http_requests_total{method="POST",status="500"} 12
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 8388608
这是 Prometheus 的 Text Exposition Format,而不是用于查询指标的 PromQL。
观察这个格式,可以发现它主要有三种类型的内容:
# HELP:描述指标的含义。# TYPE:声明指标类型。- Sample:包含指标名称、可选 Labels、指标值和可选时间戳。
此外,还允许普通注释和空行。 这意味着,我们可以先把整个文件定义为一系列 Line,然后将每一行进一步划分为不同的结构。
在写 Grammar 之前,先构思一个大致的语法:
source_file
└── line*
├── help_line
├── type_line
├── comment
├── sample
└── empty_line
这一步很重要。编写 Grammar 不仅仅是让 Parser 能识别输入,还需要考虑最终生成的 Syntax Tree 是否适合后续使用。
编写 grammar.js
Tree-sitter 使用 JavaScript DSL 定义 Grammar。
首先初始化项目:
tree-sitter init
按照 CLI 的提示创建项目,并将 Grammar 名称设置为 prom。
然后开始编辑 grammar.js。
定义入口规则
一个最简单的 Grammar 大致如下:
module.exports = grammar({
name: "prom",
extras: ($) => [" "],
rules: {
source_file: $ => repeat($._line),
_line: $ => choice(
$.sample,
$._comment_line,
"\n"
),
}
});
这里涉及几个基本概念。
name是 Grammar 的名称,在 Tree-sitter 和编辑器集成中都要用到。rules定义所有语法规则。其中source_file是根规则,也就是整个文件的入口。repeat(rule)表示某条规则可以重复零次或多次。choice(a, b, c)表示从多个候选规则中选择一个。extras定义可以出现在语法元素之间、但不影响语法结构的内容。
这里特意只将空格作为 extras,而没有使用 /\s/。原因在于,Prometheus 的文本格式是按行组织的,换行符本身具有语法意义。
如果把换行符也当作可以随意忽略的空白字符,就可能导致不同指标行之间的边界无法正确表达。
还有一个细节:规则名称以 _ 开头时,Tree-sitter 通常不会在最终 Syntax Tree 中生成对应的命名节点。
因此,_line 是一个内部组织规则,我们没有必要让它出现在最终语法树中。
定义 HELP 和 TYPE
接下来处理注释行。
_comment_line: $ => choice(
$.help_line,
$.type_line,
$.comment
),
help_line: $ => seq(
token("#"),
token("HELP"),
field("metric_name", $._metric_name),
field("metric_help", $.docstring),
"\n"
),
type_line: $ => seq(
token("#"),
token("TYPE"),
field("metric_name", $._metric_name),
field("metric_type", $._metric_type),
"\n"
),
这里新出现了几个 DSL 函数。
seq(a, b, c)表示多个语法元素按顺序出现。token(rule)用于定义词法 Token。field(name, rule)给某个子节点指定一个字段名。
例如:
field("metric_name", $._metric_name)
可以让其他工具通过 metric_name 这个字段找到对应节点,而不必依赖子节点的位置。
这对于后续编写 Query 尤其有用。
在设计语法树时,我倾向于给那些具有明确语义的节点添加 Field,例如:
metric_namemetric_valuetimestamplabel_namelabel_value
这种方式可以让 Grammar 的结构更加稳定和易于理解。
定义指标 Sample
Sample 是 Prometheus 文本格式中最重要的部分。
例如:
http_requests_total{method="GET",status="200"} 1024
它包含:
- 指标名称:
http_requests_total - Label Set:
{method="GET",status="200"} - 指标值:
1024
在 tree-sitter-prom 中,相关定义如下:
sample: $ => $.expression,
expression: $ => seq(
field("metric_name", $._metric_name),
optional($.label_set),
field("metric_value", $._metric_value),
field("timestamp", optional($._timestamp)),
"\n"
),
optional(rule) 表示一个可选的语法元素。
因此,以下形式都可以被识别:
up 1
up{instance="localhost:9090"} 1
up{instance="localhost:9090"} 1 1710000000000
这里的代码有一个值得注意的地方:
sample: $ => $.expression
sample 本身只是引用了 expression,并没有额外增加一层有意义的语法结构。
Tree-sitter 不会因为我们给规则起了 sample 这个名字,就一定在最终语法树中生成一个 sample 节点。
这一点在编写 Query 时需要特别留意:Query 匹配的是实际生成的 Node,而不是 grammar.js 中所有规则的名称。
定义 Label Set
Label Set 由一组 key=value 组成:
{method="GET",status="200"}
Grammar 定义为:
label_set: $ => seq(
"{",
optional(seq(
$.label,
repeat(seq(",", $.label)),
optional(",")
)),
"}"
),
label: $ => seq(
field("label_name", alias($._label_name, $.identifier)),
"=",
field("label_value", alias($._label_value, $.string)),
),
这里涉及两个新概念。
第一个是使用 repeat() 处理分隔符列表。
许多语言都存在类似结构:
item1, item2, item3
我们经常会写出类似的规则:
seq(
$.item,
repeat(seq(",", $.item))
)
如果允许末尾出现一个逗号,再增加:
optional(",")
这种模式在 Tree-sitter Grammar 中非常常见。
第二个是 alias()。
alias($._label_name, $.identifier)
它让一个内部规则在语法树中以指定的节点类型出现。
例如 _label_name 本来只是一个词法规则,但通过 alias(),最终可以表现为 identifier。
类似地:
alias($._label_value, $.string)
会将 Label Value 表现为 string。
这种设计有一个好处:可以在不同的上下文中复用具有相同语义的节点名称。
后续编写 highlights.scm 时,我们就不需要分别处理很多不同的 Identifier 变体。
定义基本 Token
接下来定义指标名、Label 名称和指标值。
_metric_name: $ =>
alias(/[a-zA-Z_:][a-zA-Z0-9_:]*/, $.identifier),
_label_name: $ =>
/[a-zA-Z_][a-zA-Z0-9_]*/,
_metric_value: $ => alias(
choice(
$._float_value,
"NaN",
"+Inf",
"-Inf"
),
$.number
),
Tree-sitter 可以直接使用正则表达式定义 Token。 比如:
/[a-zA-Z_:][a-zA-Z0-9_:]*/
可以用于识别传统 Prometheus 指标名称。 而指标值除了普通浮点数,还可能是:
NaN
+Inf
-Inf
因此需要使用 choice()。
到这里,一个简单的 Prometheus Parser 已经基本成型。
真实的 tree-sitter-prom 还定义了浮点数、时间戳和 Documentation String 等规则,完整实现可以参考项目中的 grammar.js。
需要说明的是,该实现主要针对传统 Prometheus 文本格式,并不等于完整覆盖所有版本的 OpenMetrics 或 Prometheus 新式命名约定。项目里的浮点数和整数规则也仍有进一步完善的空间。
从 Grammar 生成 Parser
完成 grammar.js 之后,运行:
tree-sitter generate
Tree-sitter CLI 会根据 Grammar 生成解析器,主要包括:
src/
├── parser.c
├── grammar.json
└── node-types.json
其中 parser.c 是真正负责 Parsing 的 C 代码。
这也是 Tree-sitter 的一个重要设计:Grammar 使用 JavaScript 编写,但生成的 Parser 并不需要 JavaScript 运行环境。
编辑器可以直接加载编译后的原生 Parser。
可以把这个过程理解为:
grammar.js
│
│ tree-sitter generate
▼
parser.c
│
│ 编译
▼
Native Parser
│
▼
CST
完成生成后,可以测试一个简单的文件。
例如新建 example.prom:
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET"} 1024
运行:
tree-sitter parse example.prom
输出的语法树结构大致如下(省略部分位置与匿名节点):
(source_file [0, 0] - [3, 0]
(help_line [0, 0] - [1, 0]
metric_name: (identifier [0, 7] - [0, 26])
metric_help: (docstring [0, 26] - [0, 47]))
(type_line [1, 0] - [2, 0]
metric_name: (identifier [1, 7] - [1, 26])
metric_type: (type [1, 27] - [1, 34]))
(sample [2, 0] - [3, 0]
(expression [2, 0] - [3, 0]
metric_name: (identifier [2, 0] - [2, 19])
(label_set [2, 19] - [2, 33]
(label [2, 20] - [2, 32]
label_name: (identifier [2, 20] - [2, 26])
label_value: (string [2, 27] - [2, 32])))
metric_value: (number [2, 34] - [2, 38]))))
这时候可以检查:
- 是否存在意外的
ERROR节点? - 是否存在不应该出现的
MISSING节点? - Named Node 的层次是否合理?
- Field 名称是否能够正确表达语义?
第三点尤其重要。 能够成功解析某段代码,不意味着 Parser 的设计已经足够好。 如果生成的语法树结构不合理,后面的 Syntax Highlighting、Code Folding 和 Text Objects 都可能变得很难实现。
为 Parser 编写测试
Tree-sitter 提供了 Corpus Test 机制。
测试文件放在 test/corpus/ 目录下,可以包含多个输入及其期望的语法树。
例如:
test/
└── corpus/
├── basic.txt
├── labels.txt
└── comments.txt
一个测试用例大致如下:
==================
Basic metric
==================
up 1
---
(source_file
(expression
metric_name: (identifier)
metric_value: (number)))
使用:
tree-sitter test
运行测试。
Parser 测试应该覆盖正常输入,也应该覆盖一些边界情况,例如:
- 空文件和空行
- 没有 Labels 的 Sample
- 空 Label Set
- 多个 Labels
- 带时间戳的 Sample
- 特殊数值
NaN、+Inf和-Inf - 字符串中的转义字符
- 不完整的 Label
- 缺少值或括号等非法输入
对于编辑器 Parser,错误输入尤其值得测试。
用户正在输入代码时,文件可能在很长一段时间里都不是合法的完整程序。一个好的 Parser 应该能够尽量把错误限制在局部,而不是让整个文件的语法结构全部失效。
我建议每增加一个 Grammar Rule,就同时补充相应的 Corpus Test,而不是等 Grammar 写完后再集中测试。
为 Grammar 编写测试很重要,因为它可以帮助我们在修改 Grammar 时,及时发现语法树结构的变化。而且如果要发表到官方的 Tree-sitter Parser 仓库,Corpus Test 是必须的。
Queries
使用 highlights.scm 实现语法高亮
到这里,我们已经可以把 Prometheus 文本解析为语法树。
但编辑器还不知道应该给哪些节点上色。
这就是 highlights.scm 的工作。
Tree-sitter 使用 Query Language 匹配语法树节点,并通过 Capture 名称告诉编辑器应该如何分类这些节点。
例如:
(identifier) @type
这条 Query 表示:把所有 identifier 节点标记为 @type。
编辑器再根据主题配置决定具体的颜色和样式。
按节点类型匹配
tree-sitter-prom 中有这样的规则:
[
"TYPE"
"HELP"
] @keyword
(type) @type.builtin
(identifier) @type
第一条通过方括号同时匹配 TYPE 和 HELP 两个匿名 Token。
第二条匹配 Metric Type,例如 counter 和 gauge。
第三条匹配 Identifier。
这些规则的目标不是修改语法树,而是为已经生成的节点附加可供编辑器使用的 Capture 信息。
按 Field 匹配
单纯匹配节点类型,有时不足以表达语义。 例如 Label 的 Key 和 Value,虽然都属于普通的文本内容,但应该显示成不同的高亮类别。 由于 Grammar 里定义了 Field,可以编写:
(label
label_name: (identifier) @property
label_value: (string) @string)
这样:
http_requests_total{method="GET"} 1024
其中:
method对应@property"GET"对应@string
这比单纯依赖字符串匹配更准确。 这也是前面建议为重要节点定义 Field 的原因。
使用 Predicate 实现条件匹配
Tree-sitter Query 还支持 Predicate。
例如 Prometheus Histogram 中的 le Label 具有特殊含义。
http_request_duration_seconds_bucket{le="0.5"} 42
在 tree-sitter-prom 中,有这样的规则:
(label
label_name: (identifier) @attribute.builtin
(#eq? @attribute.builtin "le"))
这里的 #eq? 表示:只有当捕获到的文本等于 le 时,才应用这个 Capture。
这种方式可以在不修改 Grammar 的情况下,根据节点内容进行更精细的分类。
不过需要注意:highlights.scm 中的 Capture 名称由最终使用它的编辑器解释,并不是所有编辑器都支持完全相同的命名规则。
语法树与高亮规则应该分开设计
在编写 Parser 时,很容易为了满足某个高亮需求而不断增加 Grammar Rule。
例如,为了让 le 显示成特殊颜色,可能会考虑把它定义成一个特殊的语法 Token。
但实际上,le 在语法层面仍然是一个普通的 Label Name。
它的特殊性属于更高一层的语义或展示需求。
因此,一个比较实用的原则是:
Grammar 负责识别结构,Query 负责解释和使用结构。
不要把所有编辑器高亮上的差异都塞进 Grammar。
通过 injections.scm 支持嵌入式语言
这是 Tree-sitter 比较有意思的功能之一。 很多时候,一门语言的代码并不是单独存在于文件中,而是嵌入另一种语言里。 比如:
- Markdown 中的 Rust 代码块
- HTML 中的 JavaScript
- YAML 中的 SQL
- TOML 或 YAML 中的 VRL
以 Vector 为例,用户经常把 VRL 写在 YAML 配置里:
transforms:
parse_logs:
type: remap
inputs:
- source
source: |
.message = upcase!(.message)
.level = "info"
对于 YAML Parser 来说,source 中的内容只是一个 Block Scalar。
但是我们希望编辑器能够把其中的代码当作 VRL 解析。
Tree-sitter 的 Language Injection 就是为这种场景设计的。
Injection 是如何工作的?
基本过程是:
YAML Source
│
▼
YAML Parser
│
▼
YAML Syntax Tree
│
▼
injections.scm
│
│ 找到 source 对应的文本范围
▼
VRL Parser
│
▼
VRL Syntax Tree
│
▼
VRL highlights.scm
也就是说,首先由外层语言的 Parser 解析整个文件,然后根据 Injection Query 找到需要交给另一种 Parser 处理的区域。
编写 Injection Query
一个简化的 YAML Injection Query 可以写成:
(block_mapping_pair
key: (flow_node
(plain_scalar
(string_scalar) @_key))
value: (block_node
(block_scalar) @injection.content)
(#eq? @_key "source")
(#set! injection.language "vrl"))
其中:
@injection.content 表示需要交给另一个 Parser 解析的文本范围。
#set! injection.language "vrl" 指定使用 VRL Parser。
不过这个例子只是为了说明 Injection 的机制,并不能直接用于所有 YAML Block Scalar。
因为 YAML 的 Block Scalar 包含 |、|- 等标记,也涉及缩进处理。如果直接将整个节点交给 VRL Parser,可能会导致解析错误。
在 tree-sitter-vrl 的 README 中,我给出了更完整的 YAML Injection 示例,利用 #match? 和 #offset! 处理 Block Scalar 的格式及解析范围。
完整示例可以参考 tree-sitter-vrl README。
还有一个容易忽略的地方:Injection Query 通常属于外层语言的 Query 配置。
例如,将 YAML 内部的某个区域解析成 VRL,就需要将匹配规则放在 YAML 对应的 injections.scm 中,或者通过编辑器机制扩展它。
并不是把这条 Query 放到 VRL 的 injections.scm 里就能够自动识别 YAML。
Parser 自身的 Injection
tree-sitter-vrl 也包含自己的 queries/injections.scm。
例如,VRL 支持正则表达式字面量:
r'^[a-zA-Z]+$'
其 Injection Query 为:
((regex) @injection.content
(#offset! @injection.content 0 2 0 -1)
(#set! injection.language "regex"))
这里将正则表达式内部的内容交给 Regex Parser 解析。
#offset! 用于调整 Capture 的范围,去掉 VRL 正则字面量外层的前缀和引号。
这样,编辑器就有机会对正则表达式内部的字符类、量词和分组等语法进行更细致的高亮。
需要注意,#offset! 等 Directive 的具体支持和行为依赖编辑器的 Query 实现,不能假定所有编辑器都完全一致。
其他常见的 Query 文件
除了 highlights.scm 和 injections.scm,一个 Tree-sitter Parser 还可以提供其他 Query。
locals.scm
locals.scm 用于描述变量定义、变量引用和作用域。
例如对于:
function hello(name) {
return name;
}
Parser 可以识别函数和变量节点,而 Locals Query 则可以进一步指出哪些节点是变量定义,哪些节点是引用。
常见 Capture 包括:
@local.scope
@local.definition
@local.reference
对于 VRL 这样包含变量和局部作用域的语言,这类 Query 会比较有用。
但它并不是一个完整的语义分析系统,也无法取代 Language Server。
folds.scm
folds.scm 用于标记可以折叠的代码范围。
例如函数体、代码块、对象等结构。
一个简单的例子:
(block) @fold
编辑器可以利用它来实现结构化代码折叠。
indents.scm
部分编辑器使用 indents.scm 来辅助自动缩进。
例如指定哪些语法节点应该增加或减少缩进层级。
textobjects.scm
Text Objects 常用于 Neovim 的结构化编辑。
例如通过 Query 捕获函数、参数或代码块,让用户能够按语法结构选择文本,而不是依赖字符或行。
需要强调的是,这些 Query 文件并不是 Tree-sitter 核心统一规定的一套跨编辑器功能接口。
不同编辑器会选择实现其中的一部分,并可能采用自己的 Capture 约定。
因此,Grammar 可以尽量保持通用,而编辑器相关的 Query 往往还需要分别维护或调整。
复杂语言的 Grammar:以 VRL 为例
Prometheus 的文本格式非常简单,基本不存在复杂的表达式嵌套。
但 VRL 是一门真正的编程语言。
例如:
.message = upcase!(.message)
if exists(.status) && .status >= 500 {
.level = "error"
} else {
.level = "info"
}
它需要处理:
- 表达式优先级
- 一元和二元运算符
- 变量赋值
- 函数调用
- 数组和对象
- 条件判断
- 路径访问
- 换行与语句边界
这些问题就不能只靠简单的 seq() 和 choice() 解决了。
表达式优先级
例如:
1 + 2 * 3
乘法的优先级高于加法。
Tree-sitter 提供 prec()、prec.left()、prec.right() 等方法处理优先级和结合性。
在 tree-sitter-vrl 中,我使用了命名优先级:
precedences: $ => [
[
"return",
"not",
"binary_factor",
"binary_add",
"merge",
"binary_comparison",
"binary_equality",
"logical",
"error_coalesce",
],
],
然后为具体运算符设置对应优先级:
prec.left("binary_factor", seq(
field("left", $._arithmetic),
field("operator", "*"),
field("right", $._arithmetic)
))
这里需要区分运算符优先级和结合性。
prec.left() 除了设置优先级,也指定左结合。对于减法这样的运算符,结合性会直接影响表达式结构。
语法冲突
有些语言结构可能产生歧义。 比如某段输入在当前状态下可以被解释成多个不同的语法结构。 Tree-sitter 提供了一些处理这种歧义的方法:
prec():静态优先级prec.dynamic():动态优先级conflicts:声明需要保留的语法歧义token.immediate():要求 Token 与前一个 Token 紧邻
在 VRL 的实现中,这些机制都能看到实际用途。
例如,VRL 中的路径访问:.foo.bar
与带空格的表达式在词法和语法层面可能需要不同处理,这时 token.immediate() 就很有用。
但不建议在遇到 Grammar Conflict 时立即添加 conflicts。
应该先检查是否存在设计不合理的规则,是否可以通过调整语法结构、明确优先级或提取公共规则来消除歧义。
复杂语言的 Grammar 设计往往是一个不断调整 Syntax Tree、测试和处理歧义的过程。 本文不展开 VRL 的所有语法细节,感兴趣可以直接阅读项目的 grammar.js。
集成
一个 Parser 生成成功后,还需要让编辑器知道:
- 如何找到并加载这个 Parser。
- 哪些文件应该使用它。
- 到哪里寻找对应的 Query 文件。
这里简单介绍如何将 Parser 集成到 Neovim 和 Zed。具体的集成方式需要读者自己去阅读编辑器的相关文档。
将 Parser 集成到 Neovim
对于 Neovim,需要区分原生 Tree-sitter API 和 nvim-treesitter 插件提供的安装管理能力。
注册 Parser
以 Neovim 原生 Tree-sitter API 为例,可以注册 Prometheus Parser:
vim.treesitter.language.add("prom", {
path = "/path/to/prom.so",
})
这里的 prom.so 是已经编译好的 Parser 动态库。
在 macOS 等系统中,原生动态库的实际文件名及加载方式取决于构建和安装环境。
推荐通过 nvim-treesitter 安装,可以省掉大部分的手动操作,不必自己手动放置动态库。
注册 Filetype
Parser 的名字和 Neovim 的 Filetype 是两个不同概念。
例如我们希望 .prom 文件使用 prom Filetype:
vim.filetype.add({
extension = {
prom = "prom",
},
})
然后让对应 Buffer 启动 Tree-sitter:
vim.api.nvim_create_autocmd("FileType", {
pattern = "prom",
callback = function()
vim.treesitter.start()
end,
})
在某些场景下,Filetype 的名称可能不等于 Tree-sitter Language 的名称。
这时可以使用:
vim.treesitter.language.register(
"prom",
"prometheus"
)
将 Filetype 映射到对应的 Parser。
安装 Query 文件
Neovim 会在 runtimepath 中寻找 Tree-sitter Query。
例如:
~/.config/nvim/
└── queries/
└── prom/
├── highlights.scm
└── injections.scm
只要这个目录位于 Neovim 的 runtimepath 中,Neovim 就可以找到相应的 Query。
如果是使用 nvim-treesitter 安装的,它会自动帮你把 Query 放到正确的位置。
可以使用:
:InspectTree
查看当前 Buffer 对应的 Tree-sitter Syntax Tree。
也可以使用:
:Inspect
检查光标位置的高亮信息。
这两个命令在开发和调试 Grammar、Query 时非常有用。
此外,在查看 InspectTree 的时候也可以结合使用 :EditQuery 交互式检查 Query 的匹配结果。这对编写自己的 Query 文件非常有帮助。
一般,我会把自己的 Query 文件放在 ~/.config/nvim/queries/<language>/ 或 ~/.config/nvim/after/queries/<language>/ 下,
这样可以覆盖或扩展那些我安装的 parser 的功能。
使用 nvim-treesitter
如果希望将自己的 Parser 作为公开语言支持提供给更多用户,可以进一步考虑加入 nvim-treesitter 的 Parser Registry。
这样,使用者就可以通过 :TSInstall vrl 安装 Parser,而不必手动编译,同时也会自动帮你安装对应的 Queries。
如果不想加入官方 Registry,也可以在自己的 Neovim 配置中添加自己的 Parser。 这里 就有一个示例。
将 Parser 集成到 Zed
Zed 同样使用 Tree-sitter,但它的语言支持通常通过 Extension 组织。
一个最小的 Zed Language Extension 可以是:
zed-prom/
├── extension.toml
└── languages/
└── prom/
├── config.toml
└── highlights.scm
其中 extension.toml 定义扩展和 Grammar 来源。
例如:
id = "prom"
name = "Prometheus Text Format"
version = "0.1.0"
schema_version = 1
[grammars.prom]
repository = "https://github.com/belltoy/tree-sitter-prom"
rev = "<commit-sha>"
这里的 rev 应替换为仓库实际的 Git Commit SHA。
Zed 会根据这个 Revision 获取并构建对应的 Grammar。
接下来定义语言配置:
# languages/prom/config.toml
name = "Prometheus Text"
grammar = "prom"
path_suffixes = ["prom"]
line_comments = ["# "]
这样 Zed 就知道哪些文件应该使用这个 Grammar。
最后,将高亮 Query 放到:
languages/prom/highlights.scm
Zed 便可以使用它进行 Syntax Highlighting。
Zed 也支持更多 Query 类型,例如:
- Bracket Matching
- Code Outline
- Indentation
- Language Injection
不过需要注意,Zed 的 Query Capture 约定与 Neovim 不完全一致,因此不能保证直接复制 tree-sitter-prom 的所有 Query 就能得到相同效果。
有的 Parser 仓库会为不同编辑器维护不同的 Query 文件。
比较合理的组织方式是: Parser 仓库负责维护通用 Grammar 和基础 Query,编辑器扩展则负责维护与编辑器特性相关的配置。
如何组织和发布一个 Tree-sitter 项目
如果只是自己使用,一个 grammar.js 加上 highlights.scm 就可能足够。
但如果希望其他编辑器和开发者也能复用,建议按以下方式组织仓库:
tree-sitter-mylang/
├── grammar.js
├── tree-sitter.json
├── package.json
├── queries/
│ ├── highlights.scm
│ └── injections.scm
├── test/
│ └── corpus/
├── src/
├── bindings/
├── README.md
└── LICENSE
src/中的生成文件通常应该提交到 Git 仓库,让下游用户和构建系统能够直接使用对应的 Parser 源码。bindings/则可以提供 Rust、Python、Node.js 等语言的绑定,让这个 Parser 不只用于编辑器,也可以被其他程序使用。
建议在 README 中至少说明:
- 支持的语言或文件格式
- 支持的主要语法特性
- 如何生成和测试 Parser
- 如何在编辑器中使用
- 已知的语法限制
- 版本和兼容性信息
还有一个实践经验:下游编辑器最好引用一个明确的 Git Revision,而不是始终跟踪 Grammar 的最新分支。 因为 Grammar 的修改可能会改变 Syntax Tree 结构。 比如原本的:
(identifier)
在一次重构后变成:
(metric_identifier)
那么依赖旧节点名称的 Query 就会失效。 这意味着,Syntax Tree 的结构本身也是 Parser 对外提供的一种接口。 一旦其他项目开始依赖它,修改节点类型、Field 名称或层次结构时,就应该考虑兼容性。
一些实践建议
结合这几个 Parser 的开发经验,我觉得认为有几个地方值得注意避免踩坑。
从 Syntax Tree 设计开始,而不是从正则表达式开始
不要一上来就试图把语言的所有语法写进 grammar.js。
先找一些有代表性的代码,思考最终希望得到怎样的 Syntax Tree。
对于编辑器工具而言,一棵易于查询、结构稳定的语法树,往往比单纯追求 Grammar 简短更重要。
保持 Grammar 和 Query 的职责清晰
Grammar 应当描述语言的语法。 Query 则负责让不同工具利用这些语法结构。 不要因为某个 Token 要显示成特殊颜色,就立即在 Grammar 中引入一个新的节点类型。
从简单的规则开始,逐步扩展
先让最基本的输入能够成功解析,然后逐步添加更复杂的规则。 对于像 VRL 这样的语言,先处理 Literal、Identifier、基础表达式,再扩展到函数、条件、闭包等结构,通常会更容易定位问题。
每次修改都检查实际的 Syntax Tree
tree-sitter generate 成功并不代表结果正确。
很多时候 Grammar 可以正常生成 Parser,但解析出来的树并不是你期待的结构。
使用 tree-sitter parse、Corpus Test,以及 Neovim 的 :InspectTree,能够更快地发现问题。
将编辑器集成作为独立的一层
一个 Grammar 并不需要绑定特定的编辑器。 可以先实现通用 Parser 和基础 Query,再由 Neovim、Zed 等编辑器各自负责 Filetype 注册、Parser 加载和 Query 适配。 这样也更有利于长期维护。
总结
为一门语言编写 Tree-sitter Parser,实际上可以拆分成几个相对独立的步骤:
- 阅读语言规范,收集具有代表性的代码示例。
- 设计 Syntax Tree,确定重要的 Named Node 和 Field。
- 使用
grammar.js定义语法。 - 生成 Parser,检查解析结果并编写 Corpus Test。
- 编写
highlights.scm实现基础语法高亮。 - 根据需要增加 Injection、Folding、Locals 等 Query。
- 将 Parser 和 Query 集成到 Neovim、Zed 等编辑器。
- 发布 Grammar,并维护其 Syntax Tree 兼容性。
对于 Prometheus 文本格式这样的简单语言,核心 Grammar 可能不到一百行,就足以提供基本的语法解析和高亮。
对于 VRL 这样更复杂的语言,则需要花更多精力处理表达式、语法歧义、换行和 Token 边界等问题。
但无论语言复杂程度如何,整个开发过程的思路基本一致。 Tree-sitter 的价值也不止于编辑器高亮。只要能够将一种文本格式解析成稳定的结构化语法树,就能进一步用于代码导航、结构化搜索、静态分析,以及其他开发工具。
如果你经常使用某种小众语言或特殊格式,甚至是自己开发的语言,却发现编辑器缺乏良好的语法支持,不妨尝试自己编写一个 Tree-sitter Parser。
可以参考官方的一些 Parser 仓库,了解它们的设计思路和 Query 组织方式。例如 tree-sitter-javascript, tree-sitter-go 等。也可以参考我在 GitHub 上维护的几个 parser:
- tree-sitter-prom Prometheus 文本格式的语法规则和 Query。
- tree-sitter-vrl VRL 语言的语法规则和 Query。
- tree-sitter-edoc Erlang 的 edoc 文档格式语法规则。
- tree-sitter-openmetrics OpenMetrics text format 语法规则,类似于 Prometheus text format,但支持更多 OpenMetrics 特性。
另外,学会写 Query 也很重要。Query 是 Tree-sitter 的一个强大功能,它可以让你在语法树上进行模式匹配、捕获节点、实现高亮、折叠、文本对象等功能。 例如,可以在 Yaml 中高亮显示嵌入的其它语言代码块,或者在你的代码中高亮显示特定的函数调用或变量。在对 tree-sitter 支持得好的编辑器中,Query 甚至可以提供更多高效的功能。