ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

External Scanners:为 Tree-sitter 语言编写手写词法逻辑的完整指南

External Scanners:为 Tree-sitter 语言编写手写词法逻辑的完整指南 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 的缩进与反缩进 tokenOff-side rule需要维护一个缩进级别栈Bash 与 Ruby 的 heredocHere document正文结束边界由前导标记动态决定Ruby 的百分号字符串Percent strings起始分隔符与结束分隔符成对出现且分隔符可以是任意字符。这些 token 的识别依赖运行时状态与上下文无法用纯正则表达。Tree-sitter 的方案是允许语法作者提供一组手写的 C 函数在词法阶段优先介入这就是外部扫描器。从生成器源码看外部 token 在语法内部是被单独处理的crates/generate/src/parse_grammar.rs 中externals作为独立的VecRuleJSON字段存在与普通 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.cCLItree-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 与 DestroyCreatevoid * tree_sitter_my_language_external_scanner_create() { // ... }负责创建扫描器对象仅在语言被设置到 parser 上时调用一次每次ts_parser_set_language都会调用。通常需要在堆上分配内存并返回指针该指针会作为payload参数传给其他四个函数。如果扫描器不需要维护任何状态直接返回NULL即可。Destroyvoid 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 与 DeserializeSerializeunsigned 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 的状态载荷用于处理编辑和歧义时恢复扫描器状态。Deserializevoid 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.cskip为 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-listDarwin 系统用-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若扫描器返回falseTree-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_boundaryEOF 边界上的前瞻行为inverted_external_token取反的外部 tokenexternal_unicode_column_alignmentget_column与 Unicode 字符的列对齐uses_current_column/depends_on_column/get_col_eof列相关行为wasm_realloc_clobber_region/wasm_realloc_overflow_heapwasm 环境下分配与重分配的正确性。每个语法目录都含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),仅供参考
返回列表