跳转到正文

AST 重写

AST 重写就是带有替换模板的 AST 搜索。Greph会运行与AST 搜索相同的匹配器,然后在匹配的字节范围内将重写后的模板拼接回源代码。周围的空白、缩进和注释都会保留;匹配到的代码本身会重新打印(参见匹配内部)。

快速示例

命令行
# Convert array() literals to []
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' --dry-run src
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' src

# isset ternary -> null coalesce
./vendor/bin/greph -p 'isset($X) ? $X : $Y' -r '$X ?? $Y' --dry-run src

# Rename a method call
./vendor/bin/greph -p '$obj->oldName($$$ARGS)' -r '$obj->newName($$$ARGS)' src

# Confirm each file before writing
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' --interactive src

替换模板

替换模板使用与搜索模式相同的元变量语法。捕获的节点会按名称拼接回去:

模式中 模板中 效果
$X $X 插入捕获的节点
$$$ARGS $$$ARGS 插入捕获的节点序列
$NAME(标识符) $NAME 插入捕获的标识符

出现在模式中、但未出现在模板中的元变量会从重写后的代码中移除。目前不会拒绝出现在模板中、但未出现在模式中的元变量:它会按字面内容打印出来,因此 -r '$o->$m($$$A, $EXTRA)'会向代码中写入一个 $EXTRA 变量(#4)。请先使用 --dry-run 检查模板。

模式

标志 行为
(无) 对每个匹配的文件应用重写
--dry-run 将重写后的文件内容打印到标准输出,不写入文件
--interactive 写入每个文件前提示 Rewrite <file>? [y/N]

默认行为是“写入所有发生变化的内容”。迭代调整模式时,先使用 --dry-run,对差异满意后再移除该标志。

保留格式

Greph不会重新格式化文件。匹配器会记录原始源代码中每个匹配项的字节范围,重写器再将渲染后的模板直接拼接到这些范围内。未触及的代码保持原样,包括:

  • 缩进和尾随空白
  • 行内注释和独立注释
  • 空行布局
  • 字符串引号样式和拼接方式
  • PHP 开始/结束标签样式

这是相对于全面重新格式化的一项有意取舍:Greph不会“整理”未匹配的代码,但也不会在无关行中引入嘈杂的差异。

匹配内部

匹配范围本身会替换为经 PHP-Parser 的美化打印器渲染的模板;捕获的节点会重新打印,而不是作为源代码文本复制。目前这意味着(#2):

  • 匹配内部未附加到后续节点的注释会被删除,例如数组最后一项后的尾随注释,或其他内容为空的数组中的注释。
  • 附加到捕获节点的注释会保留,但会移到单独一行,这可能导致单行表达式变为多行。
  • 匹配内部的换行不会保留:没有这类注释的多行 array(...) 会变成单行 [...]。
  • 字面写法会规范化:0x1F会变成 0x1f,1_000会变成 1000,"\x41" 等转义序列会被写成对应字符("A"),灵活 heredoc 会失去缩进。
PHP
// before
$d = array(
    "a" => 1,  // trailing comment
);

// after -p 'array($$$ITEMS)' -r '[$$$ITEMS]'
$d = ["a" => 1];

请检查 --dry-run 输出,留意其中包含注释或手动格式化字面量的匹配项。

多行和嵌套匹配

多行模式的工作方式与单行模式相同。替换内容会由美化打印器渲染,并插入元变量,因此被替换代码的布局由打印器决定,而非原始匹配内容。

Greph会在一次遍历中重写每个文件,绝不会应用两个相互重叠的匹配项。它会从文件末尾向前遍历匹配项,并跳过与已经重写的匹配项重叠的内容。对于起始偏移不同的嵌套匹配,会重写内部匹配项,并保持外部匹配项不变:

PHP
// before
$b = array(1, array(2));

// after one run of -p 'array($$$ITEMS)' -r '[$$$ITEMS]'
$b = array(1, [2]);

// after a second run
$b = [1, [2]];

如果两个匹配项从同一偏移处开始(例如方法链被 $obj->$method($$$ARGS) 匹配),则会重写外部匹配项。对于可以嵌套的模式,请重复运行重写,直到 --dry-run 不再输出内容(#3)。ast-grep会重写两个嵌套匹配中外层的匹配项,因此在这里,vendor/bin/sg和上游的 sg 会产生不同结果。

编程方式使用

PHP
use Greph\Greph;
use Greph\Ast\AstSearchOptions;

$rewrites = Greph::rewriteAst(
    'array($$$ITEMS)',
    '[$$$ITEMS]',
    'src',
    new AstSearchOptions(jobs: 4),
);

foreach ($rewrites as $result) {
    if (!$result->changed()) {
        continue;
    }

    echo "{$result->file}: {$result->replacementCount} replacement(s)\n";
    file_put_contents($result->file, $result->rewrittenContents);
}

Greph::rewriteAst()本身不会写入文件。提交更改(或在 --dry-run 和拒绝的 --interactive 情况下跳过更改)由 CLI 负责。这样,自动代理可以安全地调用此门面,先检查差异再应用更改。

RewriteResult提供:

  • file:绝对路径
  • originalContents:重写前的文件内容
  • rewrittenContents:重写后的文件内容
  • replacementCount:结构性替换的数量
  • changed():在 originalContents !== rewrittenContents 时返回 true 的辅助函数

何时使用重写

以下情况适合使用重写:

  • 转换范围局部且基于结构(并非重构整个文件)。
  • 你希望进行确定、可重复的重构,并且可以反复运行,直到内容不再变化。
  • 你想在应用更改前先预览。
  • 你想将重构集成到脚本或 CI 作业中,而无需调用外部二进制文件。

以下情况不适合使用重写:

  • 转换依赖于 PHP-Parser 无法从单个文件解析的类型信息。
  • 转换需要跨越文件边界添加或移除导入。
  • 你需要的是格式化工具,而不是重构工具(请使用 PHP-CS-Fixer)。