External Scanners:为 Tree-sitter 语言编写手写词法逻辑的完整指南
【免费下载链接】tree-sitterAn incremental parsing system for programming tools项目地址: https://gitcode.com/gh_mirrors/tr/tree-sitter
导读
正则在很多场景下无法描述某些词法单元(token)的结构——例如 Python 的缩进/反缩进、Bash 与 Ruby 的 heredoc、Ruby 的百分号字符串。Tree-sitter 为此提供了外部扫描器(External Scanner)机制:由语法作者用 C 语言手写一组函数,为这些特殊 token 定制识别逻辑。本指南将完整讲解外部扫描器的声明方式、五个必备回调函数、TSLexer词法接口的每个字段,以及状态序列化、错误恢复、分配器与数组工具等进阶主题,并结合本仓库源码与测试用例给出可验证的实践依据。读完本文,你将能够为自己的语法编写一个健壮、可序列化、能正确处理错误恢复的外部扫描器。
为什么需要外部扫描器
Tree-sitter 的常规词法规则基于正则表达式构建。但许多语言存在"正则难以甚至无法描述"的 token,典型例子包括:
- Python 的缩进与反缩进 token(Off-side rule):需要维护一个缩进级别栈;
- Bash 与 Ruby 的 heredoc(Here document):正文结束边界由前导标记动态决定;
- Ruby 的百分号字符串(Percent strings):起始分隔符与结束分隔符成对出现,且分隔符可以是任意字符。
这些 token 的识别依赖运行时状态与上下文,无法用纯正则表达。Tree-sitter 的方案是:允许语法作者提供一组手写的 C 函数,在词法阶段优先介入,这就是外部扫描器。
从生成器源码看,外部 token 在语法内部是被单独处理的:crates/generate/src/parse_grammar.rs 中externals作为独立的Vec<RuleJSON>字段存在,与普通 token 区分;而 crates/generate/src/dsl.js 会校验externals必须是函数并规范化其返回值。这意味着外部 token 不参与正则编译,其识别完全交给你的 C 代码。
三步接入:从 grammar.js 到 scanner.c
第一步:在 grammar 中声明 externals
在语法文件的grammar({...})中新增externals小节,列出所有外部 token 的名字。这些名字随后可以像普通规则一样在语法其他位置引用:
grammar({ name: "my_language", externals: $ => [$.indent, $.dedent, $.newline], // ... });第二步:创建 src/scanner.c
在你的语言项目中新增一个 C 源文件,其路径必须是src/scanner.c,CLI(tree-sitter generate)才能识别并把它与生成的 parser 一起编译。
在该文件中,定义一个enum类型,枚举所有外部 token 的名字。枚举成员的顺序必须与 grammar 中externals数组的顺序完全一致;成员的实际名字无关紧要(仅用于你的代码内部引用):
#include "tree_sitter/parser.h" #include "tree_sitter/alloc.h" #include "tree_sitter/array.h" enum TokenType { INDENT, DEDENT, NEWLINE }实践提示:
tree_sitter/parser.h、tree_sitter/alloc.h、tree_sitter/array.h由生成流程提供(本仓库中对应模板见 crates/generate/src/templates/alloc.h 与 crates/generate/src/templates/array.h),最终会出现在生成的 parser 头文件同一目录下。
第三步:实现五个命名回调函数
必须定义五个名字完全固定的函数,其命名模式为tree_sitter_<language_name>_external_scanner_<action>,其中 action 是 create / destroy / serialize / deserialize / scan 之一。例如语言名my_language对应tree_sitter_my_language_external_scanner_create。
生命周期函数:Create 与 Destroy
Create
void * tree_sitter_my_language_external_scanner_create() { // ... }- 负责创建扫描器对象,仅在语言被设置到 parser 上时调用一次(每次
ts_parser_set_language都会调用)。 - 通常需要在堆上分配内存并返回指针;该指针会作为
payload参数传给其他四个函数。 - 如果扫描器不需要维护任何状态,直接返回
NULL即可。
Destroy
void tree_sitter_my_language_external_scanner_destroy(void *payload) { // ... }- 负责释放扫描器占用的内存,在 parser 被删除或切换语言时调用一次。
- 参数就是 create 返回的那个指针。如果 create 没有分配任何内存,此函数可以是空操作。
从本仓库的真实测试用例可见其最小形态——test/fixtures/test_grammars/epsilon_external_tokens/scanner.c 中 create 直接return NULL;,destroy 为空函数体,用于识别零宽度 token。
状态持久化:Serialize 与 Deserialize
Serialize
unsigned tree_sitter_my_language_external_scanner_serialize( void *payload, char *buffer ) { // ... }- 把扫描器的完整状态拷贝进给定的字节缓冲区,并返回写入的字节数。
- 每次外部扫描器成功识别一个 token 后都会调用。
- 最多可写入的字节数由常量
TREE_SITTER_SERIALIZATION_BUFFER_SIZE决定,定义在tree_sitter/parser.h中。本仓库中该值为1024(见 lib/src/parser.h)。 - 这些字节最终会存入语法树(subtree 的状态载荷),用于处理编辑和歧义时恢复扫描器状态。
Deserialize
void tree_sitter_my_language_external_scanner_deserialize( void *payload, const char *buffer, unsigned length ) { // ... }- 根据 serialize 写入的字节恢复扫描器状态。
- 参数为扫描器指针、字节缓冲区指针、以及应读取的字节数。
- 最佳实践:在从缓冲区恢复值之前,先显式清零所有状态变量,避免残留旧值。
为什么序列化如此关键
从解析器实现看,扫描器状态的序列化/反序列化发生在核心库的每步解析中——lib/src/parser.c 中ts_assert(length <= TREE_SITTER_SERIALIZATION_BUFFER_SIZE)保证写入不会溢出缓冲区,而 lib/src/wasm_store.c 在 wasm 场景下同样校验该上限。因此:
- 为了 parser 正确工作,serialize 必须存下全部状态,deserialize 必须恢复全部状态;
- 为了性能,应设计紧凑、快速的状态布局(例如缩进栈只需存列号数组,而非对象结构)。
Scan:词法识别的核心
典型流程
一次成功的扫描通常按以下步骤进行:
- 若当前字符属于目标 token 的合法字符集,调用
lexer->advance前进若干次; - 可选:调用
lexer->mark_end标记 token 结束位置,并"前瞻"检查后续字符是否会令该 token 失效; - 将
lexer->result_symbol设为对应 token 类型; - 返回
true,表示成功识别出一个 token。
之后 Tree-sitter 会把结果节点压入解析栈,输入位置停留在最后一次调用lexer->mark_end的位置。
bool tree_sitter_my_language_external_scanner_scan( void *payload, TSLexer *lexer, const bool *valid_symbols ) { // ... }TSLexer 结构逐字段解析
TSLexer结构体在本仓库中的真实定义位于 lib/src/parser.h,字段如下:
int32_t lookahead— 输入流中的当前下一个字符,以 32 位 Unicode 码点表示。TSSymbol result_symbol— 识别出的符号。scan 函数应向该字段赋值枚举TokenType中的某个值。void (*advance)(TSLexer *, bool skip)— 前进到下一个字符。若skip为true,当前字符被视为被跳过的文本,不会计入外部扫描器产出 token 的文本范围——通常用于 token 开始之前跳过前导空白。token 开始之后一般应传false。特别注意:在mark_end之后调用advance(lexer, true)会影响 token 的起始位置,可能导致错误或零长度 token 范围。实现细节可参见 lib/src/lexer.c:
skip为 true 时会把token_start_position更新为当前位置,从而把该字符从 token 范围中剔除。void (*mark_end)(TSLexer *)— 标记已识别 token 的结束位置。默认情况下(不调用 mark_end),所有经advance越过的字符都计入 token 长度;一旦调用mark_end,此后的advance不会再增大返回 token 的长度。可以多次调用 mark_end 来逐步扩大 token 长度。lib/src/lexer.c 的实现还处理了一个边界:当扫描器恰好处于某个 included range 起点时,token 结束位置会被回退到上一个included range 的结尾。
uint32_t (*get_column)(TSLexer *)— 查询词法器当前的列位置,返回自当前行起始以来的码点数量。每次调用该函数都会从行首重新计算。典型用途是 Python 缩进扫描器用它取得缩进列号(见下文数组示例)。bool (*is_at_included_range_start)(const TSLexer *)— 检查 parser 是否刚刚跳过了文档中的一些字符。当使用ts_parser_set_included_ranges解析嵌入文档时(见多语言文档章节),扫描器在移动到文档不相连的部分时可能需要特殊行为。例如 EJS 文档中,JavaScript parser 用该函数在<%与%>分隔的代码指令之间插入自动分号 token。bool (*eof)(const TSLexer *)— 判断词法器是否到达文件末尾。虽然文件末尾lookahead的值为0,但必须使用该函数而非检查该值,因为0(即 "NUL" 字符)也可能是被解析文件中真实存在的合法字符。lib/src/lexer.c 的实现通过检查
current_included_range_index是否耗尽所有 range 来判断 EOF。
valid_symbols:期望性数组与不可回退
scan 函数的第三个参数是一个布尔数组,标明 parser 当前期望哪些外部 token 有效。只应在对应位置为 true 时去识别该 token。同时,扫描器无法回退,因此某些逻辑可能需要合并处理:
if (valid_symbols[INDENT] || valid_symbols[DEDENT]) { // ... 对 INDENT 和 DEDENT 共同适用的逻辑 if (valid_symbols[INDENT]) { // ... 仅适用于 INDENT 的逻辑 lexer->result_symbol = INDENT; return true; } }零宽度 token 的真实示例
本仓库的测试语法epsilon_external_tokens展示了最小化 scan 实现——它无条件返回一个零宽度 token(见 test/fixtures/test_grammars/epsilon_external_tokens/scanner.c)。这印证了"零宽度 token 可行但必须谨慎"的文档告诫。
外部扫描器辅助工具
Allocator:使用 ts_ 前缀的分配函数
不要直接使用 libc 的malloc、calloc、realloc、free,应使用tree_sitter/alloc.h中带ts_前缀的版本。这些宏允许消费者覆盖默认分配器,默认情况下则使用 libc 函数。
作为 core 库与使用分配器的 parser 库的消费者,你可以通过ts_set_allocator设置分配器,并让扫描器复用同一个分配器。启用方式:
- 编译扫描器时定义宏
TREE_SITTER_REUSE_ALLOCATOR; - 同时,core 库必须以动态链接方式链接进最终应用,因为需要在运行时解析内部函数。
如果编译的是使用 core 库的可执行文件、但想在运行时动态加载 parser,则需使用特殊链接器标志:非 Darwin 系统用--dynamic-list,Darwin 系统用-exported_symbols_list。CLI 正是这样做的(可参考 crates/cli/build.rs)。
分配 100 字节的示例:
#include "tree_sitter/parser.h" #include "tree_sitter/alloc.h" // ... void* tree_sitter_my_language_external_scanner_create() { return ts_calloc(100, 1); // 或 ts_malloc(100) } // ...Arrays:状态栈与缓冲
若扫描器需要数组类状态(例如追踪缩进栈或标签栈),使用tree_sitter/array.h中的数组宏。该头文件提供了大量宏,以下是追踪缩进栈并识别字符串的完整示例:
[!WARNING] 不要使用任何带下划线前缀、且注释标明"这不是你要找的"的数组函数或宏。这些是供其他公开宏调用的内部辅助函数,不应直接使用。
#include "tree_sitter/parser.h" #include "tree_sitter/array.h" enum TokenType { INDENT, DEDENT, NEWLINE, STRING, } // 在 create 函数中创建数组 void* tree_sitter_my_language_external_scanner_create() { return ts_calloc(1, sizeof(Array(int))); // 或者自己清零内存: Array(int) *stack = ts_malloc(sizeof(Array(int))); array_init(&stack); return stack; } bool tree_sitter_my_language_external_scanner_scan( void *payload, TSLexer *lexer, const bool *valid_symbols ) { Array(int) *stack = payload; if (valid_symbols[INDENT]) { array_push(stack, lexer->get_column(lexer)); lexer->result_symbol = INDENT; return true; } if (valid_symbols[DEDENT]) { array_pop(stack); // 按值返回被弹出的元素,这里不需要它 lexer->result_symbol = DEDENT; return true; } // 也可以用栈上的数组来跟踪字符串 Array(char) next_string = array_new(); if (valid_symbols[STRING] && lexer->lookahead == '"') { lexer->advance(lexer, false); while (lexer->lookahead != '"' && lexer->lookahead != '\n' && !lexer->eof(lexer)) { array_push(&next_string, lexer->lookahead); lexer->advance(lexer, false); } // 假设字符串长度有不超过 100 个字符的约束 if (lexer->lookahead == '"' && next_string.size <= 100) { lexer->advance(lexer, false); lexer->result_symbol = STRING; return true; } } return false; }注意字符串识别循环中!lexer->eof(lexer)的使用——这正是文档反复强调"循环遍历字符时始终使用 eof 函数"的落地写法。
外部扫描器的其他细节
优先级:先于常规词法
外部扫描器优先于Tree-sitter 的正常词法过程。当 externals 数组中列出的某个 token 在当前位置有效时,外部扫描器会首先被调用。这使得外部扫描器成为覆盖默认词法行为的强大手段,尤其是那些无法用正则词法规则、普通解析或动态优先级处理的情况。
错误恢复:哨兵 token 模式
在错误恢复期间,Tree-sitter 的第一步是以所有 token 都标记为有效的方式调用外部扫描器的 scan 函数。你的扫描器应检测并恰当处理这种情况。一个简单做法是在 externals 数组末尾添加一个未使用的"哨兵"token:
{ name: "my_language", externals: $ => [$.token1, $.token2, $.error_sentinel] // ... }然后通过检查哨兵 token 是否有效来判断是否处于错误恢复模式:
bool tree_sitter_my_language_external_scanner_scan( void *payload, TSLexer *lexer, const bool *valid_symbols ) { if (valid_symbols[ERROR_SENTINEL]) { return false; } // ... }如果不想显式处理错误恢复场景,最简单"退出"并让内部词法器接手的方式就是:当 valid_symbols 包含错误哨兵时,scan 返回false。
externals 中的字面量关键字
在 externals 数组中包含字面量关键字时,例如:
externals: $ => ['if', 'then', 'else']每当这些关键字在语法中出现时,都将由外部扫描器负责分词。这等价于声明具名 token 再对其别名化:
{ name: "my_language", externals: $ => [$.if_keyword, $.then_keyword, $.else_keyword], rules: { // 在规则中使用: if_statement: $ => seq(alias($.if_keyword, 'if'), ...), // ... } }外部关键字的分词分两阶段进行:
- 外部扫描器首先尝试识别该 token;
- 若扫描器返回
true并设置了 token,使用该 token; - 若扫描器返回
false,Tree-sitter 回退到内部词法器。
但,当你在 externals 数组中用规则引用(如$.if_keyword)却没有在 grammar 中定义对应规则时,Tree-sitter 无法回退到内部词法器——此时外部扫描器是识别这些 token 的唯一途径,必须自行完整处理。
风险警示
[!CAUTION]
- 外部扫描器极易造成死循环;
- 发射零宽度 token时必须格外小心;
- 循环遍历字符时始终使用
eof函数。
如何验证你的扫描器
本仓库提供了丰富的测试语法供对照学习,全部位于 test/fixtures/test_grammars,其中与外部扫描器直接相关的包括:
external_tokens/external_and_internal_tokens:基础外部 token 及与内部 token 的协作;external_extra_tokens/extra_non_terminals:外部 token 作为 extras 的用法;epsilon_external_tokens/epsilon_external_extra_tokens:零宽度外部 token 及其边界;external_lookahead_eof_boundary:EOF 边界上的前瞻行为;inverted_external_token:取反的外部 token;external_unicode_column_alignment:get_column与 Unicode 字符的列对齐;uses_current_column/depends_on_column/get_col_eof:列相关行为;wasm_realloc_clobber_region/wasm_realloc_overflow_heap:wasm 环境下分配与重分配的正确性。
每个语法目录都含grammar.js(与上述scanner.c)以及corpus.txt测试语料。在语言项目根目录运行tree-sitter test即可验证扫描器行为,这比单独调试 C 代码高效得多。
结语
外部扫描器是 Tree-sitter 语法体系中最灵活的扩展点:通过externals声明、src/scanner.c与五个固定命名的回调函数,你可以把任何"正则表达不了"的词法逻辑以命令式 C 代码实现,并借助序列化/反序列化契约获得增量解析与编辑恢复的完整支持。编写时牢记三条铁律——正确序列化完整状态、循环中使用eof、谨慎处理零宽度 token 与错误恢复哨兵,你的扫描器就能稳定融入 Tree-sitter 的增量解析流程。
【免费下载链接】tree-sitterAn incremental parsing system for programming tools项目地址: https://gitcode.com/gh_mirrors/tr/tree-sitter
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考