Skip to content
Belltoy

如何为一门语言编写 Tree-sitter 解析器

Published at:

如果你经常使用 Neovim、Zed 或 Helix 这样的编辑器,大概已经接触过 Tree-sitter。现在很多编辑器都使用 Tree-sitter 提供语法高亮、代码折叠、结构化选择,以及嵌入式语言支持。

对于 Rust、Go、Python 等主流语言,我们通常只需要安装现成的 Parser。但如果遇到一门比较小众的语言,或者某种特殊的配置文件或 DSL 格式,还没有 Tree-sitter 支持,该怎么办?

实际上,为一门简单语言编写 Tree-sitter Parser,并没有想象中复杂。

我之前写过几个 Tree-sitter Grammar:

VRL 是 Vector 使用的一门数据转换语言,具有变量、表达式、函数调用、条件判断、闭包等特性,语法相对复杂。 Prometheus 的文本指标格式则简单得多,基本上是按行组织的结构化文本。

本文主要使用 tree-sitter-prom 作为例子,从零介绍一个 Tree-sitter 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 语言匹配语法树节点。

编辑器可以使用同一棵语法树实现很多功能,例如:

因此,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.jsonGrammar 元数据和相关配置
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。

观察这个格式,可以发现它主要有三种类型的内容:

  1. # HELP:描述指标的含义。
  2. # TYPE:声明指标类型。
  3. 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"
    ),
  }
});

这里涉及几个基本概念。

这里特意只将空格作为 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 函数。

例如:

field("metric_name", $._metric_name)

可以让其他工具通过 metric_name 这个字段找到对应节点,而不必依赖子节点的位置。 这对于后续编写 Query 尤其有用。 在设计语法树时,我倾向于给那些具有明确语义的节点添加 Field,例如:

这种方式可以让 Grammar 的结构更加稳定和易于理解。

定义指标 Sample

Sample 是 Prometheus 文本格式中最重要的部分。

例如:

http_requests_total{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]))))

这时候可以检查:

  1. 是否存在意外的 ERROR 节点?
  2. 是否存在不应该出现的 MISSING 节点?
  3. Named Node 的层次是否合理?
  4. 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 测试应该覆盖正常输入,也应该覆盖一些边界情况,例如:

对于编辑器 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

其中:

这比单纯依赖字符串匹配更准确。 这也是前面建议为重要节点定义 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 比较有意思的功能之一。 很多时候,一门语言的代码并不是单独存在于文件中,而是嵌入另一种语言里。 比如:

以 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 提供了一些处理这种歧义的方法:

在 VRL 的实现中,这些机制都能看到实际用途。

例如,VRL 中的路径访问:.foo.bar 与带空格的表达式在词法和语法层面可能需要不同处理,这时 token.immediate() 就很有用。

但不建议在遇到 Grammar Conflict 时立即添加 conflicts。 应该先检查是否存在设计不合理的规则,是否可以通过调整语法结构、明确优先级或提取公共规则来消除歧义。

复杂语言的 Grammar 设计往往是一个不断调整 Syntax Tree、测试和处理歧义的过程。 本文不展开 VRL 的所有语法细节,感兴趣可以直接阅读项目的 grammar.js。

集成

一个 Parser 生成成功后,还需要让编辑器知道:

  1. 如何找到并加载这个 Parser。
  2. 哪些文件应该使用它。
  3. 到哪里寻找对应的 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 类型,例如:

不过需要注意,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

建议在 README 中至少说明:

还有一个实践经验:下游编辑器最好引用一个明确的 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,实际上可以拆分成几个相对独立的步骤:

  1. 阅读语言规范,收集具有代表性的代码示例。
  2. 设计 Syntax Tree,确定重要的 Named Node 和 Field。
  3. 使用 grammar.js 定义语法。
  4. 生成 Parser,检查解析结果并编写 Corpus Test。
  5. 编写 highlights.scm 实现基础语法高亮。
  6. 根据需要增加 Injection、Folding、Locals 等 Query。
  7. 将 Parser 和 Query 集成到 Neovim、Zed 等编辑器。
  8. 发布 Grammar,并维护其 Syntax Tree 兼容性。

对于 Prometheus 文本格式这样的简单语言,核心 Grammar 可能不到一百行,就足以提供基本的语法解析和高亮。

对于 VRL 这样更复杂的语言,则需要花更多精力处理表达式、语法歧义、换行和 Token 边界等问题。

但无论语言复杂程度如何,整个开发过程的思路基本一致。 Tree-sitter 的价值也不止于编辑器高亮。只要能够将一种文本格式解析成稳定的结构化语法树,就能进一步用于代码导航、结构化搜索、静态分析,以及其他开发工具。

如果你经常使用某种小众语言或特殊格式,甚至是自己开发的语言,却发现编辑器缺乏良好的语法支持,不妨尝试自己编写一个 Tree-sitter Parser。

可以参考官方的一些 Parser 仓库,了解它们的设计思路和 Query 组织方式。例如 tree-sitter-javascript, tree-sitter-go 等。也可以参考我在 GitHub 上维护的几个 parser:

另外,学会写 Query 也很重要。Query 是 Tree-sitter 的一个强大功能,它可以让你在语法树上进行模式匹配、捕获节点、实现高亮、折叠、文本对象等功能。 例如,可以在 Yaml 中高亮显示嵌入的其它语言代码块,或者在你的代码中高亮显示特定的函数调用或变量。在对 tree-sitter 支持得好的编辑器中,Query 甚至可以提供更多高效的功能。


参考资料


Share this post:

Previous Post
从客户端角度深度理解 Kafka
Next Post
AI 越来越会写代码,软件工程师的价值在哪里?