跳转到内容

结构化重构代码

本指南介绍如何让智能体完成一次大范围的机械式改动 —— 一次 codemod、一次批量 API 迁移、一种你想让它消失的写法 —— 方法是结构化搜索与重写,并让改动在真正写入之前先暂存起来供人审查。

ast_grep 按形状匹配代码。ast_edit 重写它。resolve 提交或丢弃。

结构化工具覆盖代码图能够解析的那些语言:Dart、JavaScript、TypeScript、TSX、PHP、Python、Rust、Zig、C、C++、Go、Java、Ruby、C#、Swift、Kotlin、R、Assembly、MATLAB 和 Ada。它们需要预先暂存 tree-sitter 语法,而在正式发布的构建里这些语法始终都已就位 —— 缺少某个语法会让服务器直接停止,而绝不会让搜索降级。

不要重写你还没有看过的东西。让智能体去搜索:

找出所有对我们刚刚做过空值检查的可空对象调用 dispose() 的地方。

ast_grep(pattern: "if ($X != null) $X.dispose()", language: "dart")

输出是 file:line 加上每个位置的匹配源码。

标记 匹配的内容
$NAME 任意单个节点,会被捕获
$$$NAME 任意一串同级节点 —— 参数、语句
$_ 任意内容,捕获但不记名

最关键的一点:重复出现的 $NAME 必须捕获相同的文本。

if ($X != null) $X.dispose()

它会找到那种“销毁自己刚刚检查过的对象”的守卫。它不会找到 if (a != null) b.dispose() —— 而那通常正是你要找的 bug。任何文本搜索都表达不了这一点。

按顺序尝试两件事:

  1. 把它改小。 先用 foo($X),再试 if (a) { foo($X); }。一段单独看并不是合法代码的模式,往往并不是你想要的那个结构。
  2. 标明语言。 只有当 path 指向单个文件时,language: 才会被推断出来。

没有任何解析上下文接受的模式会被报告为无法解析 —— 工具会如实说明,而不是对一段从未按它表面样子被解读的东西报告“无匹配”。

ast_edit(
pattern: "if ($X != null) $X.dispose()",
rewrite: "$X?.dispose()",
language: "dart",
)

结果是一份提案:

(proposed) ast_edit: if ($X != null) $X.dispose() → $X?.dispose()
12 replacements in 4 files:
lib/features/a/x.dart (3)
lib/features/b/y.dart (5)
…
Nothing has been written. Call resolve with edit_id "edit_1" and action
"accept" to apply it, or "discard" to drop it.

此时磁盘上还什么都没有。读一遍这份清单,抽查几个搜索报告过的位置,然后:

resolve(edit_id: "edit_1", action: "accept")

或者

resolve(edit_id: "edit_1", action: "discard")

以结构化方式做这件事的意义就在于它足够“宽”。而正是这种大范围改动,最容易让一个看起来合理的重写弄坏类型。在完成 resolve 之后请求诊断:

lsp(action: "diagnostics", file: "*")

file: "*" 运行的是项目自己的检查器(dart analyze、tsc --noEmit、cargo check、go build),而不是针对单文件的查询 —— 在一次触及四十个文件的改动之后,这才是形态正确的问题。

假设 log(msg, level) 要改成 log(level: level, message: msg)。

ast_grep(pattern: "log($MSG, $LEVEL)", language: "dart")

先看看命中结果。如果其中一些其实是另一个双参数 log,就把 path 限定到拥有你想要的那个 log 的目录来收窄模式,而不是试图在模式里表达这种区别。

ast_edit(
pattern: "log($MSG, $LEVEL)",
rewrite: "log(level: $LEVEL, message: $MSG)",
path: "lib/features/telemetry",
language: "dart",
)

然后再 resolve,再执行 lsp(action: "diagnostics", file: "*")。

场景 更合适的工具
重命名一个符号 lsp_rename —— 它跟随的是引用,而不是形状
查找一个字符串、一条注释或一个文件名 search(内容)/ search_files(名称)
“改了这里会破坏什么?” 代码图
语法不覆盖的语言 search 加上 edit