Tree-sitter 是一个用于解析源代码的工具和库,核心作者是 Max Brunsfeld 。它的核心目标是:快速、增量地把代码转换成语法树(Syntax Tree) 。它现在被很多编辑器、IDE、代码分析工具使用,例如 Atom(最初主要使用者)、Zed、GitHub 的代码高亮/代码跳转等等。上篇文章介绍的 Bearer SAST 工具就依赖于 Tree-sitter。
Tree-sitter 简介
在 Tree-sitter 出现之前,大部分编辑器(比如老版本的 Vim)对代码进行语法高亮时,用的是正则表达式(Regex)。正则的缺点是:它只能一行一行、一段一段地去匹配字符串,根本不理解代码的 上下文结构。Tree-sitter 则把源代码转成具体语法树(Concrete Syntax Tree,CST) ,并在编辑时增量更新,而不是全量重解析,适合:
编辑器实时高亮、折叠、括号匹配
代码导航(跳定义、找引用)
轻量静态检查、格式化、AI 代码理解
Tree-sitter 的核心特性:
特性
说明
增量解析
代码修改后,只重新解析变更部分,而非整个文件
容错解析
语法错误不会导致整个解析失败,会生成部分 AST 并标记错误节点
多语言支持
已有 50+ 语言grammar,覆盖主流编程语言
高性能
C 语言实现,性能极快
无依赖性
运行时库(用纯C11编写)可以嵌入任何应用程序中
查询语言
提供类 S-expression 的模式匹配语言,精确检索 AST 结构
AST vs CST
AST(Abstract Syntax Tree,抽象语法树)和 CST(Concrete Syntax Tree,具体语法树,也叫 Parse Tree)是编译器、解释器以及代码分析工具中的两个核心概念。
通常编译器(如 GCC、Clang、Go 编译器)产出的 AST(Abstract Syntax Tree,抽象语法树)是抽象 的:它只保留程序的语义结构,丢掉了括号、逗号、分号等 语法糖。这对编译优化是合理的,但对代码分析工具来说却是不行的。来看同一个函数,两种树的对比:
源码:
1 2 3 func add (a int , b int ) int { return a + b }
AST(抽象语法树)只保留语义 :注意:(、)、,、{、} 这些语法符号都消失了。位置信息可能带也可能不带:
1 2 3 4 5 6 7 8 9 FunctionDecl ├── name: "add" ├── params: │ ├── Param(name="a", type=int) │ └── Param(name="b", type=int) ├── return_type: int └── body: └── ReturnStmt └── BinaryOp(op="+", left=Ident("a"), right=Ident("b"))
而 Tree-sitter CST(具体语法树)则尽量保留一切信息 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 (source_file (function_declaration ← 命名节点:有语义 (identifier) ← "add" (parameter_list ← 参数列表是一个完整的子树 ( ← 匿名节点:左括号 (parameter_declaration name: (identifier) ← "a" type: (type_identifier)) ← "int" , ← 匿名节点:逗号 (parameter_declaration name: (identifier) ← "b" type: (type_identifier)) ← "int" )) ← 匿名节点:右括号 result: (type_identifier) ← "int" body: (block { ← 匿名节点:左花括号 (return_statement (binary_expression left: (identifier) ← "a" right: (identifier)) ← "b" ) ← return语句结束 }))) ← 匿名节点:右花括号
特性
传统 AST
Tree-sitter CST
括号 ( ) { }
丢弃
保留为匿名节点
逗号 , 分号 ;
丢弃
保留为匿名节点
每个节点有位置信息
不一定
必定有 (行号、列号、字节偏移)
语法错误
解析失败
容错 :插入 ERROR/MISSING 节点,继续解析
注释
通常丢弃
保留为 extra 节点
精确还原源码
不能
能 (从 CST 可以完全还原原始源码)
为什么代码安全扫描工具需要 CST 而不是 AST?
精确位置标注 :安全报告必须指出漏洞在第几行第几列,CST 每个节点都有位置;AST 可能丢失这个信息
注释中蕴含安全线索 :// TODO: fix SQL injection、// nosemgrep、// bearer:disable 等注释在 AST 中被丢弃,但在 CST 中完整保留
精确还原代码片段 :安全报告需要展示漏洞代码片段,CST 通过 node.Content(source) 精确提取,AST 做不到
容错解析 :被扫描的代码很可能有语法错误(开发者还没写完),AST 解析器直接失败,CST 尽可能继续并标记错误
Tree-sitter 实际使用
go-tree-sitter 是 tree-sitter C 库的 Go 语言封装,通过 CGO 调用 C 实现。Bearer 项目就是基于这个库构建了完整的 SAST 扫描引擎。注意,tree-sitter 官方也提供了 Go 封装 ,但是由于 Bearer 项目使用的是 smacker/go-tree-sitter 来快速感受一下 Tree-sitter。
基础 API 速览
首先我们来理解其提供的一些基础概念和 API。
核心对象关系 :
Parser:解析器,需要设置 Language
Language:语法定义,每种语言一个(如 javascript.GetLanguage())
Tree:解析结果树,包含所有节点
Node:树中的单个节点
Query:预编译的模式匹配查询
QueryCursor:查询执行器,迭代匹配结果
TreeCursor:树遍历器,高效深度优先遍历
如下则展示了使用 go-tree-sitter 的核心流程:
1 2 3 4 5 6 7 8 9 10 11 parser := sitter.NewParser() parser.SetLanguage(javascript.GetLanguage()) tree := parser.Parse(nil , []byte (sourceCode)) root := tree.RootNode() tree.Close() parser.Close()
示例:获取 CST/AST 结构
接下来我们通过一个实际例子,通过解析 Go 代码,获取其 CST 结构,从直观上感受下 CST 结构是怎么样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 package mainimport ( "fmt" "strings" sitter "github.com/smacker/go-tree-sitter" "github.com/smacker/go-tree-sitter/golang" ) func main () { sourceCode := `package main import "fmt" func add(a int, b int) int { return a + b } func main() { result := add(3, 5) fmt.Println(result) } ` parser := sitter.NewParser() parser.SetLanguage(golang.GetLanguage()) tree := parser.Parse(nil , []byte (sourceCode)) root := tree.RootNode() fmt.Println("=== Go AST 示例 ===" ) fmt.Println() fmt.Printf("根节点类型: %s\n" , root.Type()) fmt.Printf("根节点子节点数: %d\n" , root.ChildCount()) fmt.Println() fmt.Println("=== S-Expression 格式的 AST ===" ) fmt.Println(root.String()) fmt.Println() fmt.Println("=== 遍历顶层命名子节点 ===" ) for i := uint32 (0 ); i < root.NamedChildCount(); i++ { child := root.NamedChild(int (i)) fmt.Printf(" [%d] type=%s, content=\"%s\"\n" , i, child.Type(), truncate(child.Content([]byte (sourceCode)), 40 )) } fmt.Println() fmt.Println("=== 深入分析 func_declaration ===" ) for i := uint32 (0 ); i < root.NamedChildCount(); i++ { child := root.NamedChild(int (i)) if child.Type() == "function_declaration" { printFunctionDeclaration(child, []byte (sourceCode)) } } tree.Close() parser.Close() } func printFunctionDeclaration (node *sitter.Node, source []byte ) { name := node.ChildByFieldName("name" ) if name != nil { fmt.Printf(" 函数名: %s\n" , name.Content(source)) } params := node.ChildByFieldName("parameters" ) if params != nil { fmt.Printf(" 参数列表: %s\n" , params.Content(source)) fmt.Printf(" 参数节点子节点数: %d\n" , params.ChildCount()) for j := uint32 (0 ); j < params.ChildCount(); j++ { p := params.Child(int (j)) fmt.Printf(" [%d] type=%s, content=\"%s\"\n" , j, p.Type(), p.Content(source)) } } returnType := node.ChildByFieldName("result" ) if returnType != nil { fmt.Printf(" 返回类型: %s\n" , returnType.Content(source)) } body := node.ChildByFieldName("body" ) if body != nil { fmt.Printf(" 函数体: %s\n" , truncate(body.Content(source), 60 )) fmt.Printf(" 函数体子节点数: %d\n" , body.NamedChildCount()) } fmt.Println() } func truncate (s string , maxLen int ) string { if len (s) > maxLen { return s[:maxLen] + "..." } return strings.ReplaceAll(s, "\n" , "\\n" ) }
程序的运行结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 === Go AST 示例 === 根节点类型: source_file 根节点子节点数: 8 === S-Expression 格式的 AST === (source_file (package_clause (package_identifier)) (import_declaration (import_spec path: (interpreted_string_literal))) (function_declaration name: (identifier) parameters: (parameter_list (parameter_declaration name: (identifier) type: (type_identifier)) (parameter_declaration name: (identifier) type: (type_identifier))) result: (type_identifier) body: (block (return_statement (expression_list (binary_expression left: (identifier) right: (identifier)))))) (function_declaration name: (identifier) parameters: (parameter_list) body: (block (short_var_declaration left: (expression_list (identifier)) right: (expression_list (call_expression function: (identifier) arguments: (argument_list (int_literal) (int_literal))))) (expression_statement (call_expression function: (selector_expression operand: (identifier) field: (field_identifier)) arguments: (argument_list (identifier))))))) === 遍历顶层命名子节点 === [0] type=package_clause, content="package main" [1] type=import_declaration, content="import "fmt"" [2] type=function_declaration, content="func add(a int, b int) int { return a +..." [3] type=function_declaration, content="func main() { result := add(3, 5) fmt...." === 深入分析 func_declaration === 函数名: add 参数列表: (a int, b int) 参数节点子节点数: 5 [0] type=(, content="(" [1] type=parameter_declaration, content="a int" [2] type=,, content="," [3] type=parameter_declaration, content="b int" [4] type=), content=")" 返回类型: int 函数体: {\n return a + b\n} 函数体子节点数: 1 函数名: main 参数列表: () 参数节点子节点数: 2 [0] type=(, content="(" [1] type=), content=")" 函数体: {\n result := add(3, 5)\n fmt.Println(result)\n} 函数体子节点数: 2
tree-sitter 的 String() 方法输出的是类 S-Expression 格式,自动省略了匿名节点(括号、逗号等)以减少视觉噪声。但通过 ChildCount() 和 Child(i) 遍历时,匿名节点是存在的。这正是 CST 比 AST 胖 的地方
ChildCount() 包含匿名节点,NamedChildCount() 只算命名节点
ChildByFieldName() 通过语义字段名精确获取子节点(如 name、body)
node.Content(source) 从原始源码中提取节点对应的文本片段
参数列表的匿名子节点是 (、,、),命名子节点是 parameter_declaration
S-Expression
上文说过,Tree-sitter 可以用类 S-Expression 来表示。S-Expression(全称 Symbolic Expression,中文常译为 S-表达式)是一种用于表示树状结构数据的文本格式。它最初由计算机科学家 John McCarthy 在 1950 年代为 Lisp 编程语言设计。虽然它历史悠久,但由于其结构极其简单且表现力强,至今仍活跃在许多现代技术领域中。
S-表达式的语法规则非常简单,主要由两部分组成:
原子(Atom):最小的数据单元。可以是数字(如 42, 3.14)、字符串(如 “hello”)或符号/标识符(如 x, +, print)
列表(List):用圆括号 () 包裹、由空格分隔的元素集合。列表中的元素可以是原子,也可以是另一个列表(嵌套列表)
1 2 3 4 5 ; 这是一个简单的列表,包含三个原子 (apple banana orange) ; 这是一个嵌套列表 (fruits (apple banana) (orange grape))
在 Lisp 家族的编程语言中,S-Expression 不仅用来表示数据,还用来表示程序代码。S-Expression 的第一个元素通常是操作符/函数,后面的元素是参数(即前缀表示法)。例如对于数据表达式 $2 + 3 \times 4$,其 S-Expression 写法对应于:
Tree-sitter 对传统 S-表达式 进行了扩展,使用 : 来表示字段名(Field Name)。标准的 S-表达式(如 Lisp 中使用的)只关心元素的顺序和嵌套,不包含键值对的概念。但在解析复杂的编程语言源代码时,一个语法节点可能会包含很多子节点。为了更精确地定位和区分这些子节点的角色,Tree-sitter 引入了 field_name: (node)。这就像在标准的树状结构上打上了标签(Label),让程序(或人类)在遍历这棵树时,能一眼看出这个子节点扮演的是什么角色
语法结构: (父节点 字段名: (子节点))
例如对于 import_spec path: (interpreted_string_literal))
import_spec 是父节点(导入规范)
path 是 字段名(Field),明确告诉你后面紧跟的这个字符串是导入的 路径,而不是其他东西
(interpreted_string_literal) 是子节点(解释型字符串字面量)
Query 语法
基础语法
Tree-sitter Query(树查询)是一套基于 S-表达式 的模式匹配语法,用于在 CST(具体语法树)中筛选、定位、提取指定语法节点,广泛用于代码高亮、语法检查、代码分析、代码片段提取等场景。
查询语句由一个或多个模式(Pattern) 组成,每个模式都是标准 S-表达式,作用是匹配语法树中的节点及层级关系。基础格式:
外层括号包裹目标节点
括号内第一个值为节点类型,节点类型都是预定义好的,例如 call_expression、`
后续可选拼接子节点的匹配表达式,用于约束子节点结构
例如:
1 2 3 4 5 ; 匹配一个二元表达式,且它的两个子节点都必须是数字字面量 (binary_expression (number_literal) (number_literal)) ; 匹配任意一个包含字符串字面量子节点的二元表达式(其余子节点是什么无所谓) (binary_expression (string_literal))
为了让模式更精确,可以在子节点前加上字段名和冒号 :
1 2 ; 匹配一个赋值表达式,其左边(left)必须是一个成员表达式,且该成员表达式的对象(object)必须是一个调用表达式 (assignment_expression left: (member_expression object: (call_expression)))
如果你想限制某个节点绝对不能包含某个字段,可以在字段名前面加上感叹号 !:
1 2 ; 匹配一个类声明,要求它有名字(name),但【绝对不能有】类型参数(!type_parameters) (class_declaration name: (identifier) @class_name !type_parameters)
上文提到的括号语法只适用于 命名节点(如变量、表达式)。对于代码中的字面量符号、操作符(即匿名节点),需要使用双引号 "" 包裹起来匹配。
1 2 ; 匹配一个二元表达式,要求它的操作符必须是 "!=",且右边(right)必须是 null (binary_expression operator: "!=" right: (null))
还有一些特殊节点:
通配符(Wildcard _):类似于正则表达式中的点号 .,用来匹配任意节点
(_):匹配任意命名节点(Named Node)。
_:匹配任何节点
1 2 ; 匹配一个函数调用(call)内部的任意命名节点,并将其捕获为 call.inner (call (_) @call.inner)
错误节点(ERROR):当解析器遇到无法识别的代码时会生成 (ERROR) 节点。它也可以被直接查询,非常适合用来写语法检查工具。
缺失节点(MISSING):当代码漏掉了某些符号(例如漏了分号),Tree-sitter 能够自动修复并在树中插入一个长度为 0 的虚拟节点。这些节点可以用 (MISSING) 捕获。
1 2 3 (MISSING) @missing-node (MISSING identifier) @missing-identifier ; 捕获缺失的标识符 (MISSING ";") @missing-semicolon ; 捕获缺失的分号
运算符(Operators)
Tree-sitter 查询中支持各类运算符,包含节点捕获、量词、分组、多选、锚点五大类核心能力,用于拓展模式匹配的规则与范围。
在编写匹配规则时,你通常需要对匹配到的特定节点做后续处理。捕获(Capture)可以为模式中的节点绑定自定义名称,后续就能通过该名称引用对应的节点。捕获名称写在目标节点之后,以符号 @ 开头 。
1 2 3 4 5 该规则匹配「将函数赋值给标识符」的语法结构,并把左侧标识符捕获为 the-function-name: (assignment_expression left: (identifier) @the-function-name right: (function))
可以使用后置量词 +、*、? 匹配连续的同级重复节点,用法与正则表达式中的同名量词高度一致。
量词
作用
等价规则
+
匹配 1 个及以上 重复的目标模式
至少出现一次
*
匹配 0 个及以上 重复的目标模式
可选,可出现多次
?
标记模式为可选
0 个 或 1 个
可以使用圆括号将一组同级节点打包为一个整体,实现多节点连续匹配
1 2 3 4 5 6 匹配「一条注释紧跟一个函数声明」的组合结构: ( (comment) (function_declaration) )
使用方括号 [] 定义多选分支,括号内编写多个互斥的匹配模式,只要命中其中任意一个分支,即判定匹配成功。逻辑等价于正则中的 字符集 [] 或分支 |。
1 2 3 4 5 6 7 8 匹配函数调用语句,函数主体可以是「普通标识符」或「对象属性」,并分别做不同捕获: (call_expression function: [ (identifier) @function (member_expression property: (property_identifier) @method) ])
锚点运算符为英文句点 .,用于约束子节点的匹配位置与相邻关系。锚点会忽略语法树中的 匿名节点(标点、空格、括号等),仅针对命名节点生效。根据 . 在规则中的位置,分为三种使用场景:
场景 1:锚点放在第一个子节点前,匹配「父节点的第一个命名子节点」
场景 2:锚点放在最后一个子节点后,匹配「父节点的最后一个命名子节点」
场景 3:锚点放在两个子节点之间,仅匹配「相邻同级节点」
谓词(Predicates)和指令(Directives)
你可以在查询模式的任意位置添加谓词 S-表达式,以此为匹配规则自定义附加元数据与判断条件。格式规范如下:
以 # 开头、? 结尾
表达式内部可传入任意数量 @捕获名 或 普通字符串 作为参数
作用:对已匹配到的节点做二次过滤,不满足条件则舍弃当前匹配结果。
Tree-sitter 支持以下几类谓词:
等值系列:eq? 家族,用于对捕获节点的文本内容做精确字符串比对
1 2 3 4 5 6 ( (pair key: (property_identifier) @key-name value: (identifier) @value-name) (#eq? @key-name @value-name) )
正则系列:match? 家族,使用正则表达式匹配节点文本
多值匹配断言:any-of?:判断捕获节点的文本是否等于给定字符串列表中的任意一个,适用于批量匹配多个固定关键字、内置变量等场景
属性断言:is? / is-not?,用于判断捕获节点是否具备特定内置属性
指令(Directives)与谓词作用相似,都可以为查询模式附加元数据与处理逻辑,核心区别:
断言:以 ? 结尾,作用是条件过滤(不满足则放弃匹配);
指令:以 ! 结尾,作用是附加操作/元数据(不改变匹配结果,仅做后置处理)
Tree-sitter CLI 默认支持以下三类指令
set!:为当前匹配规则绑定键值对格式的自定义元数据,后续代码可读取该元数据,常用于语法注入、语法分类等场景。
select-adjacent!:用于过滤捕获内容,仅保留与另一个指定捕获节点相邻的节点
strip!:基于正则表达式移除捕获节点中的指定文本,不会改变语法树结构,仅修改最终提取的文本内容
Query 实战
接下来我们仍然通过一个实际例子来加深对 Query 的理解。示例代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 package mainimport ( "fmt" "strings" sitter "github.com/smacker/go-tree-sitter" "github.com/smacker/go-tree-sitter/javascript" ) func main () { sourceCode := `class UserService { constructor(apiUrl) { this.apiUrl = apiUrl; this.token = null; } async login(username, password) { const response = await fetch(this.apiUrl + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); this.token = response.token; return this.token; } getUser(id) { return fetch(this.apiUrl + "/users/" + id, { headers: { Authorization: "Bearer " + this.token } }); } } ` parser := sitter.NewParser() parser.SetLanguage(javascript.GetLanguage()) tree := parser.Parse(nil , []byte (sourceCode)) root := tree.RootNode() fmt.Println("=== JavaScript Query 示例 ===" ) fmt.Println() fmt.Println("--- 查找所有类声明 ---" ) runQuery(root, []byte (sourceCode), []byte (`(class_declaration name: (identifier) @class_name)` ), javascript.GetLanguage()) fmt.Println() fmt.Println("--- 查找所有方法定义 ---" ) runQuery(root, []byte (sourceCode), []byte (`(method_definition name: (property_identifier) @method_name) @method` ), javascript.GetLanguage()) fmt.Println() fmt.Println("--- 查找所有 async 方法 ---" ) runQuery(root, []byte (sourceCode), []byte (`(method_definition "async" name: (property_identifier) @async_method_name)` ), javascript.GetLanguage()) fmt.Println() fmt.Println("--- 查找所有 fetch 调用 ---" ) runQuery(root, []byte (sourceCode), []byte (`(call_expression function: (identifier) @func_name (#eq? @func_name "fetch")) @fetch_call` ), javascript.GetLanguage()) fmt.Println() fmt.Println("--- 查找所有成员属性访问 (this.xxx) ---" ) runQuery(root, []byte (sourceCode), []byte (`(member_expression object: (this) @this_obj property: (property_identifier) @prop_name) @this_access` ), javascript.GetLanguage()) tree.Close() parser.Close() } func runQuery (root *sitter.Node, source []byte , queryStr []byte , lang *sitter.Language) { q, err := sitter.NewQuery(queryStr, lang) if err != nil { fmt.Printf(" 查询错误: %s\n" , err) return } defer q.Close() qc := sitter.NewQueryCursor() defer qc.Close() qc.Exec(q, root) for { m, ok := qc.NextMatch() if !ok { break } captures := make (map [string ]string ) for _, c := range m.Captures { name := q.CaptureNameForId(c.Index) captures[name] = c.Node.Content(source) } var parts []string for name, content := range captures { parts = append (parts, fmt.Sprintf("%s=\"%s\"" , name, truncate(content, 50 ))) } fmt.Printf(" 匹配 %d: %s\n" , m.PatternIndex, strings.Join(parts, ", " )) } } func truncate (s string , maxLen int ) string { s = strings.ReplaceAll(s, "\n" , "\\n" ) if len (s) > maxLen { return s[:maxLen] + "..." } return s }
程序运行结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 === JavaScript Query 示例 === --- 查找所有类声明 --- 匹配 0: class_name="UserService" --- 查找所有方法定义 --- 匹配 0: method="constructor(apiUrl) {\n this.apiUrl = apiUrl;\n...", method_name="constructor" 匹配 0: method="async login(username, password) {\n const respo...", method_name="login" 匹配 0: method="getUser(id) {\n return fetch(this.apiUrl + "/us...", method_name="getUser" --- 查找所有 async 方法 --- 匹配 0: async_method_name="login" --- 查找所有 fetch 调用 --- 匹配 0: func_name="fetch", fetch_call="fetch(this.apiUrl + "/login", {\n method: "PO..." 匹配 0: fetch_call="fetch(this.apiUrl + "/users/" + id, {\n heade...", func_name="fetch" --- 查找所有成员属性访问 (this.xxx) --- 匹配 0: this_access="this.apiUrl", this_obj="this", prop_name="apiUrl" 匹配 0: this_obj="this", prop_name="token", this_access="this.token" 匹配 0: this_access="this.apiUrl", this_obj="this", prop_name="apiUrl" 匹配 0: this_access="this.token", this_obj="this", prop_name="token" 匹配 0: this_access="this.token", this_obj="this", prop_name="token" 匹配 0: this_access="this.apiUrl", this_obj="this", prop_name="apiUrl" 匹配 0: this_access="this.token", this_obj="this", prop_name="token"
小结
这篇文章我们学习了 tree-sitter 的基本用法,Bearer 项目基于 go-tree-sitter 构建了完整的 SAST 引擎,学习 tree-sitter 的基本用法,可以帮助我们后续理解 Bearer 的实现原理打下基础。