Tree-sitter 查询语法(Query Syntax)完全指南:从 S-表达式模式匹配到捕获、谓词与指令 Tree-sitter 查询语法Query Syntax完全指南从 S-表达式模式匹配到捕获、谓词与指令【免费下载链接】tree-sitterAn incremental parsing system for programming tools项目地址: https://gitcode.com/gh_mirrors/tr/tree-sitter本指南系统讲解 tree-sitter 查询语言的完整语法涵盖 S-表达式模式、字段约束、匿名节点、通配符与ERROR/MISSING/超类型节点以及量化、锚点、捕获、谓词predicates与指令directives等高级特性。读者学完后能够直接编写可用于语法高亮、代码导航、代码注入与错误检测的查询文件并理解这些模式在 查询核心实现 中的解析与匹配机制。一、查询与模式S-表达式基础一条查询query由一个或多个模式pattern组成每个模式都是一个 S-表达式S-expression用于匹配语法树中的某一类节点。匹配某个节点的表达式由一对括号包含两部分内容节点的类型名以及可选的一系列用于匹配该节点子节点的 S-表达式。例如下面这个模式会匹配所有两个子节点都是number_literal的binary_expression节点(binary_expression (number_literal) (number_literal))子节点也可以省略。例如下面这个模式会匹配任意一个至少有一个子节点是string_literal的binary_expression(binary_expression (string_literal))模式之间无需分隔符同一条查询中可以书写任意多个模式每个模式独立执行匹配。二、字段Fields通常让模式更精确的一个好做法是指定与子节点关联的 字段名。做法是在子模式前加上字段名 冒号。例如下面的模式匹配一个assignment_expression要求它的left子节点是member_expression且该member_expression的object是call_expression(assignment_expression left: (member_expression object: (call_expression)))字段名本身定义在语法文法中对应node-types.json里的fields字段它让匹配从任意子节点收敛为特定槽位的子节点从而大幅提高模式的精度与可读性。三、否定字段Negated Fields还可以约束模式使其只匹配缺少某个字段的节点。做法是在父模式内部加入一个以!为前缀的字段名。例如下面这个模式匹配一个没有类型参数的类声明(class_declaration name: (identifier) class_name !type_parameters)!type_parameters相当于断言当前节点必须不存在名为type_parameters的字段。四、匿名节点Anonymous Nodes带括号的节点写法只适用于 命名节点named nodes。要匹配具体的匿名节点如关键字、运算符、标点需要把它的名字写在双引号中。例如下面这个模式匹配运算符为!且右操作数为null的binary_expression(binary_expression operator: ! right: (null))其中!就是匿名 token 字面量(null)是命名节点。匿名节点同样可以出现在字段约束之后也可以单独作为模式的一部分与其他模式并列。五、特殊节点Special Nodes5.1 通配符节点Wildcard Node通配符节点用下划线_表示可以匹配任意节点类似于正则表达式中的.。它有两种形态(_)匹配任意命名节点_匹配任意命名或匿名节点。例如下面这个模式匹配调用表达式中的任意节点(call (_) call.inner)在 查询实现 中通配符对应一个内部专用的WILDCARD_SYMBOL并被作为普通符号参与状态机的编译与匹配见query.c中关于通配符步骤的解析逻辑。5.2ERROR节点当解析器遇到无法识别的文本时会在语法树中把它表示为(ERROR)节点。这类错误节点可以像普通节点一样被查询(ERROR) error-node5.3MISSING节点如果解析器能够通过插入一个缺失的 token 然后归约的方式从错误文本中恢复并且该树具有最低的错误代价那么它就会在最终树中插入这个缺失节点。这些缺失节点在树中看起来像是普通节点但它们的宽度为 0 个 token并且在内部实现上只是被插入的那个终结符节点的一个属性而不是像ERROR节点那样自成一类节点。这些特殊的缺失节点可以用(MISSING)来查询(MISSING) missing-node这对于检测语法树中的所有语法错误非常有用因为缺失节点不会被(ERROR)查询捕获。还可以查询特定类型的缺失节点(MISSING identifier) missing-identifier (MISSING ;) missing-semicolon缺失节点与ERROR节点的差异也体现在解析器的错误恢复逻辑中插入缺失 token 后节点宽度为零、位置与相邻 token 重合。仓库的语法测试语料中即有实例例如 next_sibling_from_zwt 的语料 在输入abdef时预期产出(MISSING c)节点eof_repeat_terminated 的语料 则展示了(ERROR)出现在树中的情形。5.4 超类型节点Supertype Nodes某些节点类型在文法中被标记为超类型supertype。超类型是一种包含多个子类型的节点类型。例如在 JavaScript 文法示例 中expression就是可以表示binary_expression、call_expression、identifier等各种表达式的一个超类型。可以在查询中使用超类型来匹配它的任意子类型而不必把每个子类型逐一列出。例如下面这个模式匹配任意种类的表达式即使它在语法树中并不是一个可见节点(expression) any-expression要查询超类型的特定子类型可以使用supertype/subtype语法。例如下面这个模式只有当binary_expression是expression的子节点时才匹配它(expression/binary_expression) binary-expression这种语法同样适用于匿名节点。例如下面这个模式只在()是expression的子节点时才匹配它(expression/()) empty-expression从源码结构看supertype/subtype的合法性在查询编译期就会被校验query.c会通过ts_language_subtypes查询某个超类型的所有子类型并检查给定的子类型是否在其中若不在则会返回TSQueryErrorNodeType错误见 query.c 中关于 supertype symbol 的解析与校验逻辑。六、捕获Captures匹配模式时往往需要进一步处理模式中的特定节点。捕获capture允许把名字与模式中的节点关联起来之后就可以用这些名字引用对应节点。捕获名写在所引用节点的后面并以开头。例如下面这个模式匹配任何把function赋值给identifier的赋值表达式并把名字the-function-name关联到那个 identifier(assignment_expression left: (identifier) the-function-name right: (function))再如这个模式匹配所有方法定义把the-method-name关联到方法名、the-class-name关联到包含该方法的类名(class_declaration name: (identifier) the-class-name body: (class_body (method_definition name: (property_identifier) the-method-name)))捕获名遵循常见的标识符命名习惯可用-、.等字符组织层级如variable.builtin、call.inner捕获是否在结果中暴露由调用方决定未加的节点仅用于约束结构、不会被返回。七、量化操作符Quantification Operators可以使用后缀的和*重复操作符来匹配兄弟节点的重复序列其语义与正则表达式中的、*类似匹配一个及以上次重复*匹配零个及以上次。例如这个模式匹配一个或多个注释组成的序列(comment)这个模式匹配类声明并在存在装饰器的情况下捕获所有装饰器(class_declaration (decorator)* the-decorator name: (identifier) the-name)还可以用?操作符把节点标记为可选。例如这个模式匹配所有函数调用并捕获字符串实参如果存在的话(call_expression function: (identifier) the-function arguments: (arguments (string)? the-string-arg))量化与捕获组合时的默认语义值得注意当量化捕获如(comment) c与谓词配合时默认要求所有被捕获节点都满足谓词如需任一满足即可请使用any-前缀见下文谓词一节。八、分组兄弟节点Grouping Sibling Nodes还可以使用括号来分组兄弟节点的序列。例如这个模式匹配注释后跟一个函数声明( (comment) (function_declaration) )上文提到的所有量化操作符、*、?也都可以应用于分组。例如这个模式匹配逗号分隔的数字序列( (number) (, (number))* )注意这里的(number)与(, (number))*是兄弟关系第一项是必选的数字随后是零个或多个逗号数字对。分组与量化组合是编写分隔列表类结构最常用的手法。九、选择Alternations选择alternation写成一对方括号[]内含一列可选模式类似于正则表达式中的字符类[abc]匹配 a、b 或 c 之一。例如下面这个模式匹配对变量或对象属性的调用。如果是变量捕获为function如果是属性捕获为method(call_expression function: [ (identifier) function (member_expression property: (property_identifier) method) ])这个模式匹配一组可能的关键字 token并把它们捕获为keyword[ break delete else for function if return try while ] keyword选择中的候选项可以自身带量化选择整体也可以再带量化符。以下示例以 C 预处理指令为例说明了这两种情况的差异;;; SOURCE CODE ;;; ; #include foo ; #include bar ; #include baz ; // comment ;;;;;;;;;;;;;;;;;;; [ (preproc_include) (comment) ] capture ; ^ 产生一次匹配包含四个捕获 ; [ ; #include foo\n, ; #include bar\n, ; #include baz\n, ; // comment, ; ] ; ; 正则等价写法: [ab] [ (preproc_include) (comment) ] capture ; ^ 产生两次匹配一次含三个捕获、一次含一个捕获 ; [ ; #include foo\n, ; #include bar\n, ; #include baz\n, ; ], ; [ ; // comment, ; ] ; ; 正则等价写法: a|b [ (preproc_include) (comment) ] capture ; ^ 产生四次匹配每次含一个捕获 ; [ ; #include foo\n, ; ], ; [ ; #include bar\n, ; ], ; [ ; #include baz\n, ; ], ; [ ; // comment, ; ] ; ; 正则等价写法: [ab]可以看到量化符作用在整个选择上会合并匹配与捕获量化符作用在单个候选项上则会把候选项拆成独立的匹配分支。十、锚点Anchors锚点操作符.用于约束子模式的匹配方式。它放在查询中的不同位置会有不同的行为。10.1 首子节点锚点当.放在父模式中的第一个子节点之前时该子节点只在它是父节点的第一个命名节点时才会匹配。例如下面的模式对给定的array节点最多匹配一次仅当array中的第一个节点是identifier时把the-element赋给它(array . (identifier) the-element)如果没有这个锚点模式会对数组中每一个 identifier 各匹配一次the-element依次绑定到每个匹配到的 identifier。10.2 尾子节点锚点类似地锚点放在模式最后一个子节点之后时会使该子模式只匹配那些父节点的最后一个命名子节点的节点。下面的模式只匹配block中最后一个命名子节点(block (_) last-expression .)10.3 兄弟节点之间的锚点最后锚点放在两个子模式之间时会要求这两个模式只匹配紧邻的兄弟节点。下面的模式给定一个类似a.b.c.d的长点号名称只匹配连续的标识符对a, b、b, c和c, d(dotted_name (identifier) prev-id . (identifier) next-id)没有锚点时a, c、b, d这类非连续组合也会被匹配。锚点施加的约束会忽略匿名节点。10.4 锚点与量化符、分组的组合当锚点紧邻一个量化节点*、、?时其含义取决于锚点是位于两个模式之间还是位于父节点的边缘。锚点位于两个子模式之间时约束两个被匹配节点是紧邻兄弟如果其中一侧是匹配了零个节点的量化符那一侧就没有节点锚点不施加任何约束。例如给定(translation_unit (comment)* doc . (function_definition) function)前面没有注释的function_definition依然能匹配此时doc什么都不捕获当注释存在时它们必须紧邻函数之前。锚点位于节点模式的起点或终点前置或后置.时约束匹配序列从父节点的第一个命名子节点开始、或结束于父节点的最后一个命名子节点。如果紧邻该边缘的模式元素是匹配了零个节点的量化符约束会作用于模式实际匹配到的最近节点。例如给定(preproc_if (preproc_def) def . (preproc_else)? else .)尾锚点要求最后匹配到的节点是父节点的最后一个命名子节点当存在preproc_else时它必须在最后当它不存在时最后一个preproc_def必须在最后。同理如果两个兄弟之间的可选量化节点匹配了零个节点两侧的锚点会合并为一个共同约束外层节点。例如给定(translation_unit (declaration) a . (comment)* . (function_definition) b)如果没有注释(declaration)和(function_definition)必须是紧邻兄弟查询才会匹配。锚点不允许出现在分组(...)或选择[...]内的第一个或最后一个位置。因为分组或选择并不是节点没有第一个/最后一个子节点可以锚定那一侧也不存在可锚定的兄弟。例如应写(comment)* doc . (function)而不是((comment) doc .)? (function)。十一、谓词Predicates可以在模式中的任何位置添加谓词S-表达式以指定与模式关联的任意元数据与条件。谓词以#开头、以?结尾的谓词名起始后面可以跟任意数量的前缀捕获名或字符串。需要特别说明的是谓词与指令见下节并不由 tree-sitter C 核心库直接处理而是以结构化形式暴露出来由上层代码执行过滤。核心库只是在编译查询时把它们解析成predicate_steps数组存放见 query.c 中ts_query__parse_predicate与predicate_steps相关代码真正执行谓词的是各语言绑定如 Rust crate 或 WebAssembly 绑定以及 CLI。目前 CLI 默认支持以下谓词11.1#eq?谓词族这一族谓词用于对单个捕获或字符串值进行匹配。第一个参数必须是捕获第二个参数可以是捕获比较两个捕获的文本或字符串把第一个捕获的文本与字符串比较。基础谓词是#eq?它的补谓词#not-eq?用于不匹配某个值。另外可以给二者加上any-前缀表示任意一个被捕获节点满足谓词即可——这只有在处理量化捕获时才有意义因为默认情况下量化捕获要求所有被捕获节点都满足谓词。于是共有四个谓词#eq?#not-eq?#any-eq?#any-not-eq?考虑下面这个针对 C 的示例((identifier) variable.builtin (#eq? variable.builtin self))这个模式匹配任何文本为self的 identifier。再看下面的示例( (pair key: (property_identifier) key-name value: (identifier) value-name) (#eq? key-name value-name) )这个模式匹配键值对中 value 是与 key 文本相同的 identifier即二者相同的情况。如前所述any-前缀用于量化捕获。下面的示例在一组注释中找出空注释((comment) comment.empty (#any-eq? comment.empty //))11.2#match?谓词#match?与#eq?族类似但用正则表达式而不是字符串比较来匹配捕获的文本。第一个参数必须是捕获第二个参数必须是包含正则表达式的字符串。与#eq?族一样可以在谓词前加not-来取反加any-来匹配量化捕获中的任意节点。下面这个模式匹配以SCREAMING_SNAKE_CASE风格书写的标识符((identifier) constant (#match? constant ^[A-Z][A-Z_]))下面这个查询识别 C 中以三个斜杠///开头的文档注释((comment) comment.documentation (#match? comment.documentation ^///\\s.*))下面这个查询在 Go 注释中找到紧跟在Cimport 语句之前的 C 代码即 Cgo 注释用于向 Go 程序注入 C 代码((comment) injection.content . (import_declaration (import_spec path: (interpreted_string_literal) _import_c)) (#eq? _import_c \C\) (#match? injection.content ^//))注意这里同时组合了锚点.把注释与 import 声明约束为紧邻兄弟、#eq?与#match?多个谓词展示了复杂注入场景的典型写法。11.3#any-of?谓词#any-of?允许把一个捕获与多个字符串匹配当捕获的文本等于其中任意一个字符串时即匹配。下面这个查询匹配 JavaScript 中的任意内建变量((identifier) variable.builtin (#any-of? variable.builtin arguments module console window document))11.4#is?谓词#is?允许断言某个捕获具有给定属性。它使用并不广泛但 CLI 会用它来判断某个节点是否是局部变量例如((identifier) variable.builtin (#match? variable.builtin ^(arguments|module|console|window|document)$) (#is-not? local))由于使用了#is-not? local这个模式匹配任何不是局部变量的内建变量。十二、指令Directives与谓词类似指令也是把任意元数据与模式关联的方式。谓词与指令的唯一区别在于指令以!结尾而不是?。12.1#set!指令#set!允许把键值对关联到模式上键和值可以是任意文本。例如((comment) injection.content (#match? injection.content /[*\/][!*\/]?[^a-zA-Z]) (#set! injection.language doxygen))这个模式匹配任何包含 Doxygen 风格注释的注释并把injection.language键设为doxygen。在编程层面当遍历该模式的捕获时可以读取这个属性然后用 Doxygen 解析器去解析该注释——这正是代码注入injection机制的常见实现方式。12.2#select-adjacent!指令#select-adjacent!允许过滤与某个捕获关联的文本只保留与另一个捕获相邻的节点。它接受两个参数都是捕获名。12.3#strip!指令#strip!允许从捕获中移除文本。它接受两个参数第一个是要剥离文本的捕获第二个是用于匹配文本的正则表达式凡是被正则表达式匹配到的文本都会被从该捕获关联的文本中移除。#select-adjacent!与#strip!的完整示例见 代码导航文档。十三、谓词与指令小结#eq?对捕获或字符串做直接匹配#match?对正则表达式做匹配#any-of?对字符串列表做匹配#is?检查捕获上的属性在这些谓词前加not-会取反匹配默认情况下量化捕获只有在所有节点都满足谓词时才匹配在eq、match谓词前加any-则改为任意节点满足即匹配#set!把键值对关联到模式#select-adjacent!过滤捕获文本只保留与另一捕获相邻的节点#strip!从捕获中移除文本[!NOTE] 谓词和指令并非由 tree-sitter C 库直接处理而是以结构化形式暴露出来供上层代码执行过滤。不过更上层的 tree-sitter 绑定如 Rust crate 或 WebAssembly 绑定确实实现了上述常用谓词。未来可能还会加入更多标准谓词与指令。十四、查询 API 与执行模型进阶虽然本页聚焦查询语法但了解模式最终如何被执行有助于写出更高效的查询。核心 C 库的查询 API 定义在 api.h实现于 query.cTSQuery *ts_query_new( const TSLanguage *language, const char *source, uint32_t source_len, uint32_t *error_offset, TSQueryError *error_type );如果查询本身有错误error_offset会被设为错误的字节偏移error_type会被设为指示错误类型的枚举值typedef enum { TSQueryErrorNone 0, TSQueryErrorSyntax, TSQueryErrorNodeType, TSQueryErrorField, TSQueryErrorCapture, } TSQueryError;TSQuery是不可变的可以安全地在线程间共享。要执行查询需要创建携带查询处理状态的TSQueryCursor查询游标不应在线程间共享但可以被多次查询执行复用TSQueryCursor *ts_query_cursor_new(void);然后在给定的语法节点上执行查询void ts_query_cursor_exec(TSQueryCursor *, const TSQuery *, TSNode);之后迭代匹配结果typedef struct { TSNode node; uint32_t index; } TSQueryCapture; typedef struct { uint32_t id; uint16_t pattern_index; uint16_t capture_count; const TSQueryCapture *captures; } TSQueryMatch; bool ts_query_cursor_next_match(TSQueryCursor *, TSQueryMatch *match);当没有更多匹配时该函数返回false否则会用匹配信息填充match包括哪个模式被匹配、哪些节点被捕获。限制查询范围可以用字节偏移或点行、列位置来限制查询执行的范围bool ts_query_cursor_set_byte_range(TSQueryCursor *self, uint32_t start_byte, uint32_t end_byte); bool ts_query_cursor_set_point_range(TSQueryCursor *self, TSPoint start_point, TSPoint end_point);这两个函数返回与给定范围相交的匹配即使匹配只有部分与范围重叠也可能被返回。此外还有包含containing变体只返回所有被捕获节点都完全落在范围内的匹配bool ts_query_cursor_set_containing_byte_range(TSQueryCursor *self, uint32_t start_byte, uint32_t end_byte); bool ts_query_cursor_set_containing_point_range(TSQueryCursor *self, TSPoint start_point, TSPoint end_point);[!NOTE] 对所有上述函数结束值0被视为无界即最大值。因此传入字节范围(0, 0)或点范围{0, 0}, {0, 0}会匹配整棵树而不是一个空范围。十五、查询语法的实战场景查询语法在整个 tree-sitter 生态中被广泛复用几个典型的落地点包括单元测试系统tree-sitter 的测试体系见 编写测试使用同样的 S-表达式模式描述期望的语法树结构测试文件按标题 输入 期望树的格式组织例如(source_file (function_definition ...))这样的模式即与查询语法同源。语法高亮与代码导航高亮查询见 语法高亮通过keyword、function、variable.builtin等捕获名驱动主题渲染#select-adjacent!与#strip!的实战组合见 代码导航。错误检测用(ERROR)与(MISSING)捕获语法错误节点可以在编辑器与 CI 中定位残缺代码。代码注入用#set! injection.language ...配合injection.content捕获把嵌入片段交给对应语言的解析器处理。CLI 实操可以通过tree-sitter query命令直接对文件执行查询文件见 query 命令文档快速验证模式是否符合预期。掌握本页的语法要素字段、否定字段、匿名节点、特殊节点、捕获、量化、分组、选择、锚点、谓词与指令就拥有了在任意 tree-sitter 语法树上进行结构化代码分析的基础能力。【免费下载链接】tree-sitterAn incremental parsing system for programming tools项目地址: https://gitcode.com/gh_mirrors/tr/tree-sitter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考