AST将「第三个参数」从正则表达式近似匹配变成精确查询。关键:每个节点带type、span(字节偏移)、children列表、命名字段;span使AST能精确定位源码行号,无span的parser几乎无用。
AST 不是因为你用它优雅才使用的数据结构。它是唯一能把「这个函数每一次调用的第三个参数」从「一个勉强能用的正则表达式」变成「一个正确的查询」的东西。
无论使用什么解析器,语法树中的每个节点都携带四样东西:类型(function_definition、call、identifier)、在源码中的跨度——字节偏移量,通常还有行列位置,这样你可以把节点映射回具体的文本——子节点列表,以及给子节点按角色打标签的命名字段。
跨度是让 AST 有用武之地、不仅仅用于编译的关键。一个按函数边界切分的分块器,只需要每个 function_definition 节点的起始和结束字节,别的什么都不需要;一个要高亮正确行号的搜索结果,需要节点的起始位置。任何给你树但不给你字节偏移量的解析器,对这类工作几乎毫无用处。
"abstract"这个词在名称中承担了真正的工作。这棵树不代表文本,它代表文本所表示的结构。用于分组的括号不会作为节点存活下来,因为它们的全部含义已经被吸收到树的形状中了。'x'和"x"之间的选择也不会,一行写完还是四行写完也不会。这恰恰就是为什么 AST 是询问行为相关问题的正确工具、而询问格式相关问题的错误工具——本文最后一部分要阐明的正是这个区别。
取四行 Python 代码并转储其语法树。Python 标准库有 AST 模块,所以不需要安装任何东西:
import ast
src = """
def charge(order, rate):
total = order.subtotal * (1 + rate)
return round(total, 2)
"""
tree = ast.parse(src)
print(ast.dump(tree.body[0], indent=2))
FunctionDef(
name='charge',
args=arguments(
args=[arg(arg='order'), arg(arg='rate')]),
body=[
Assign(
targets=[Name(id='total', ctx=Store())],
value=BinOp(
left=Attribute(
value=Name(id='order', ctx=Load()),
attr='subtotal', ctx=Load()),
op=Mult(),
right=BinOp(left=Constant(value=1),
op=Add(),
right=Name(id='rate', ctx=Load())))),
Return(
value=Call(
func=Name(id='round', ctx=Load()),
args=[Name(id='total', ctx=Load()), Constant(value=2)],
keywords=[]))])
读懂这个结构能给你文本所不能提供的信息。乘法和加法是独立嵌套正确的 BinOp 节点,所以操作符优先级已经解析好了——你永远不需要再为此费神。order.subtotal 是一个 Attribute 节点,value 和 attr 是独立的部分,所以「每一次对 .subtotal 的访问」就是一个节点类型的查询。每个 Name 都携带一个上下文:total 被赋值处是 Store,被读取处是 Load,这是定义和使用的区别,也是文本匹配最难做对的一件事。
遍历它用的是访问者模式。下面这个例子收集了所有调用了 round 的函数:
class FindRound(ast.NodeVisitor):
def __init__(self):
self.current = None
self.hits = []
def visit_FunctionDef(self, node):
prev, self.current = self.current, node.name
self.generic_visit(node) # descend; without this, nothing
self.current = prev
def visit_Call(self, node):
f = node.func
if isinstance(f, ast.Name) and f.id == "round":
self.hits.append((self.current, node.lineno))
self.generic_visit(node)
v = FindRound(); v.visit(tree); print(v.hits)
# [('charge', 4)]
在递归下降前后对 self.current 的保存和恢复不是附带操作——它是你把嵌套节点归因到其外围定义的方式,如果你使用单个可变变量而不做恢复,这就是嵌套函数和方法会打破的模式。忘记调用 generic_visit 是另一个经典 bug:访问者会静默停止下降并报告零个匹配。
按位置的子节点是脆弱的。在 tree-sitter 的语法中——大多数代码索引工具都构建于其上的多语言解析器生成器——子节点还可以通过字段名来寻址,查询也可以对它们加以约束:
(function_definition
name: (identifier) @fn
parameters: (parameters) @params
body: (block) @body)
name: 和 body: 是字段名,@fn 是一个捕获,你通过它获取匹配的节点。如果写 children[1] 来代替,一旦出现装饰器、类型注解或 async 关键字就会出问题。字段在语法修订中比位置稳定,所以应该用 node.child_by_field_name("name") 而不是索引。
Python 的 ast.parse 在遇到第一个问题时抛出 SyntaxError 并什么都不返回。对于编译器来说这是正确的。对于索引一个代码库来说这是不可接受的,因为真实的代码库包含带有未解决合并冲突标记的文件、使用了比你解析器更新的语法的文件、带有占位符标记的模板文件,以及编辑器缓冲区中尚未写完的代码。一个这样的文件应该让你丢掉那个文件,而不是丢掉整个运行。
tree-sitter 会恢复:它总是返回一棵树,把无法解析的部分表示为 ERROR 节点,在期望出现 token 的位置插入零宽度的 MISSING 节点。损坏区域之外的一切都正常解析,所以一个函数坏掉的文件仍然能给出另外四十个函数。在索引时,检查一个块子树中是否有任何 ERROR 节点,把它记录为一个质量标记而不是丢弃这个块。
在代码库规模下,另一重要属性是增量重解析。tree-sitter 允许你描述一个编辑操作并复用之前的树:
tree.edit(
start_byte=142, old_end_byte=142, new_end_byte=160,
start_point=(7, 4), old_end_point=(7, 4), new_end_point=(7, 22),
)
new_tree = parser.parse(new_source, tree)
for r in tree.changed_ranges(new_tree):
print(r.start_byte, r.end_byte) # only these chunks need re-embedding
changed_ranges 是解析和索引维护之间的直接链接:它告诉你文件的哪些字节范围实际上具有不同的语法结构,这比「文件变了」这个范围要小得多。这就是提交钩子教程中描述的「只重新嵌入移动部分」机制背后的原理。
Python 绑定的 API 在最近几个版本中有变化——Query 和 QueryCursor 现在是独立的类,旧的 language.query(...) 辅助方法已经没了。在复制任何 tree-sitter 代码片段之前,先检查你装的是哪个版本,包括本文中的这些。
这个区别不是卖弄学问,它决定了你能用哪个工具。Concrete syntax tree 保留每个 token,包括标点、注释和确切的空白字符,所以源码可以逐字节从树中重构出来。Abstract syntax tree 丢弃编译器不需要的东西——Python 的 ast 完全丢弃了注释,你无法从树中找回它们。
对于索引来说这非常重要,因为文档字符串和开头注释通常是函数中检索价值最高的文本。如果你的分块器从抽象树构建文本,就会静默丢弃它们。tree-sitter 生成的是 concrete tree,这就是它通常是代码索引和任何需要保留格式的源码重写工具的原因。当你想要语义并且控制输入时用 ast;当源码是任意的且文本和结构同等重要时,用 concrete-tree 解析器。
Extracting a Call Graph From a Codebase
Semantic Diff: Comparing Two Versions of a Function by Meaning, Not Text
Chunking for RAG: Size, Overlap and Semantic Splitting