PHP AST 操作:使用 nikic/php-parser 进行代码修改与静态分析
大家好,今天我们来深入探讨 PHP AST (Abstract Syntax Tree) 操作,并重点介绍如何利用 nikic/php-parser 这个强大的库进行代码修改和静态分析。AST 作为代码的一种结构化表示,为我们提供了程序理解和操纵的底层基础。通过掌握 AST 的相关技术,我们可以实现诸如代码重构、静态分析、代码生成等高级功能。
1. 什么是 AST?
AST,即抽象语法树,是源代码语法结构的一种树状表示形式。它忽略了源代码中的一些细节,比如空格、注释等,只保留了代码的骨架和语义信息。每个节点代表源代码中的一个构造,例如变量、表达式、语句、函数等。
与源代码相比,AST 具有以下优点:
- 结构化: 更容易遍历和操作。
- 抽象化: 忽略了不重要的语法细节,专注于语义。
- 标准化: 不同语言的 AST 结构可能相似,便于跨语言分析。
例如,对于以下 PHP 代码:
<?php
$x = 1 + 2;
echo $x;
?>
其 AST 可能会表示为一棵树,根节点代表整个 PHP 文件,子节点分别代表赋值语句 $x = 1 + 2; 和 echo $x;。赋值语句又可以分解为变量 $x、操作符 = 和加法表达式 1 + 2,以此类推。
2. nikic/php-parser 简介
nikic/php-parser 是一个用 PHP 编写的 PHP 解析器,可以将 PHP 代码解析成 AST。它提供了以下核心功能:
- 解析 PHP 代码: 将 PHP 代码字符串解析成 AST 对象。
- 遍历 AST: 提供多种遍历 AST 的方式,如访问者模式和节点查找。
- 修改 AST: 允许修改 AST 节点,从而实现代码转换。
- 输出 PHP 代码: 可以将修改后的 AST 重新生成 PHP 代码。
nikic/php-parser 是目前 PHP 社区中使用最广泛的 AST 解析器,被许多静态分析工具和代码重构工具所采用。
安装:
可以使用 Composer 安装 nikic/php-parser:
composer require nikic/php-parser
3. 使用 nikic/php-parser 解析 PHP 代码
首先,我们需要创建一个 Parser 对象,并使用其 parse() 方法将 PHP 代码解析成 AST。
<?php
require_once 'vendor/autoload.php';
use PhpParserParserFactory;
$code = <<<'CODE'
<?php
$x = 1 + 2;
echo $x;
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); // 可以指定 PHP 版本
try {
$ast = $parser->parse($code);
} catch (PhpParserError $error) {
echo "Parsing error: {$error->getMessage()}n";
exit(1);
}
// $ast 现在包含了代码的 AST
var_dump($ast);
?>
这段代码首先引入了 Composer 的自动加载器,然后定义了一段简单的 PHP 代码。接下来,创建了一个 ParserFactory 对象,并使用 create() 方法创建了一个 Parser 对象,指定了 PHP 7 的语法。最后,调用 parse() 方法将代码解析成 AST,并将结果存储在 $ast 变量中。 var_dump($ast) 会打印出AST的结构,方便我们理解。
ParserFactory::PREFER_PHP7 是指定解析PHP代码时使用的语法版本。它可以选择其他版本例如 ParserFactory::PREFER_PHP5 或 ParserFactory::PREFER_PHP8。
4. AST 结构和节点类型
nikic/php-parser 定义了大量的节点类型,用于表示 PHP 代码中的各种语法结构。一些常见的节点类型包括:
PhpParserNodeStmtClass_: 表示类定义。PhpParserNodeStmtFunction_: 表示函数定义。PhpParserNodeStmtIf_: 表示 if 语句。PhpParserNodeExprVariable: 表示变量。PhpParserNodeExprBinaryOpPlus: 表示加法表达式。PhpParserNodeScalarLNumber: 表示整数。PhpParserNodeStmtEcho_: 表示 echo 语句
等等。
每个节点都包含一些属性,用于描述其具体信息。例如,PhpParserNodeExprVariable 节点包含一个 name 属性,表示变量名。PhpParserNodeExprBinaryOpPlus 节点包含 left 和 right 属性,分别表示左操作数和右操作数。
可以使用 instanceof 运算符来判断节点的类型。
<?php
require_once 'vendor/autoload.php';
use PhpParserNodeExprVariable;
use PhpParserNodeExprBinaryOpPlus;
use PhpParserNodeScalarLNumber;
use PhpParserNodeStmtEcho_;
use PhpParserNodeStmtExpression;
use PhpParserParserFactory;
$code = <<<'CODE'
<?php
$x = 1 + 2;
echo $x;
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
foreach ($ast as $node) {
if ($node instanceof Expression) {
$expression = $node->expr;
if ($expression instanceof PhpParserNodeExprAssign) {
// 处理赋值语句
$variable = $expression->var;
$expressionValue = $expression->expr;
if ($variable instanceof Variable && $expressionValue instanceof Plus) {
echo "发现变量赋值语句: $" . $variable->name . " = " . get_class($expressionValue) . "n";
echo "左操作数是 " . get_class($expressionValue->left) . "n";
echo "右操作数是 " . get_class($expressionValue->right) . "n";
if ($expressionValue->left instanceof LNumber && $expressionValue->right instanceof LNumber) {
echo "加法表达式的值是: " . ($expressionValue->left->value + $expressionValue->right->value) . "n";
}
}
}
} elseif ($node instanceof Echo_) {
echo "发现 echo 语句n";
}
}
?>
这段代码遍历了 AST,并判断每个节点的类型。如果节点是赋值语句,则输出变量名和表达式的类型。如果节点是加法表达式,则输出左右操作数的类型,如果左右操作数都是数字,则计算加法表达式的值。
5. 遍历 AST
nikic/php-parser 提供了多种遍历 AST 的方式:
NodeTraverser: 使用访问者模式遍历 AST,可以自定义访问者来处理不同类型的节点。NodeVisitor: 定义访问者类,实现enterNode()和leaveNode()方法,分别在进入和离开节点时执行操作。NodeFinder: 查找特定类型的节点。
5.1 使用 NodeTraverser 和 NodeVisitor
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
class MyNodeVisitor extends NodeVisitorAbstract {
public function enterNode(Node $node) {
if ($node instanceof NodeStmtEcho_) {
echo "发现 echo 语句n";
} elseif ($node instanceof NodeExprVariable) {
echo "发现变量: $" . $node->name . "n";
}
return null;
}
public function leaveNode(Node $node) {
// 可选:在离开节点时执行操作
return null;
}
}
$code = <<<'CODE'
<?php
$x = 1 + 2;
echo $x;
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$traverser->addVisitor(new MyNodeVisitor);
$traverser->traverse($ast);
?>
这段代码定义了一个 MyNodeVisitor 类,实现了 enterNode() 方法。在 enterNode() 方法中,判断节点的类型,如果是 NodeStmtEcho_ 节点,则输出 "发现 echo 语句",如果是 NodeExprVariable 节点,则输出变量名。然后,创建了一个 NodeTraverser 对象,并将 MyNodeVisitor 添加到 traverser 中。最后,调用 traverse() 方法遍历 AST。
5.2 使用 NodeFinder
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeFinder;
use PhpParserParserFactory;
$code = <<<'CODE'
<?php
$x = 1 + 2;
echo $x;
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$finder = new NodeFinder;
$variables = $finder->findInstanceOf($ast, NodeExprVariable::class);
foreach ($variables as $variable) {
echo "发现变量: $" . $variable->name . "n";
}
?>
这段代码创建了一个 NodeFinder 对象,并使用 findInstanceOf() 方法查找所有 NodeExprVariable 类型的节点。然后,遍历找到的变量节点,并输出变量名。
6. 修改 AST
nikic/php-parser 允许修改 AST 节点,从而实现代码转换。修改 AST 节点通常需要以下步骤:
- 遍历 AST,找到需要修改的节点。
- 创建新的节点对象,替换原来的节点。
- 使用
NodeTraverser的traverse()方法应用修改。
6.1 替换节点
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
use PhpParserNodeScalarString_;
use PhpParserPrettyPrinterStandard;
class StringReplacer extends NodeVisitorAbstract {
public function enterNode(Node $node) {
if ($node instanceof NodeScalarString_) {
// 将所有字符串替换为 "Hello, World!"
return new String_("Hello, World!");
}
return null;
}
}
$code = <<<'CODE'
<?php
echo "Original String";
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$traverser->addVisitor(new StringReplacer);
$modifiedAst = $traverser->traverse($ast);
$prettyPrinter = new Standard;
$newCode = $prettyPrinter->prettyPrint($modifiedAst);
echo $newCode; // 输出: <?php echo "Hello, World!";
?>
这段代码定义了一个 StringReplacer 类,实现了 enterNode() 方法。在 enterNode() 方法中,判断节点的类型,如果是 NodeScalarString_ 节点,则创建一个新的 NodeScalarString_ 节点,内容为 "Hello, World!",并返回该节点,从而替换原来的节点。然后,创建了一个 NodeTraverser 对象,并将 StringReplacer 添加到 traverser 中。最后,调用 traverse() 方法遍历 AST,应用修改,并将修改后的 AST 存储在 $modifiedAst 变量中。 使用 PrettyPrinterStandard 类将修改后的 AST 重新生成 PHP 代码。
6.2 添加节点
添加节点通常需要在现有的节点中插入新的子节点。例如,可以在函数体的开头添加一条语句。
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
use PhpParserNodeStmtEcho_;
use PhpParserNodeScalarString_;
use PhpParserPrettyPrinterStandard;
class AddEchoStatement extends NodeVisitorAbstract {
public function enterNode(Node $node) {
if ($node instanceof NodeStmtFunction_) {
// 在函数体的开头添加 echo "Hello from function!";
$newNode = new Echo_([new String_("Hello from function!")]);
array_unshift($node->stmts, $newNode);
}
return null;
}
}
$code = <<<'CODE'
<?php
function myFunction() {
echo "Original Code";
}
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$traverser->addVisitor(new AddEchoStatement);
$modifiedAst = $traverser->traverse($ast);
$prettyPrinter = new Standard;
$newCode = $prettyPrinter->prettyPrint($modifiedAst);
echo $newCode;
/* 输出:
<?php
function myFunction()
{
echo "Hello from function!";
echo "Original Code";
}
*/
?>
这段代码定义了一个 AddEchoStatement 类,实现了 enterNode() 方法。在 enterNode() 方法中,判断节点的类型,如果是 NodeStmtFunction_ 节点,则创建一个新的 NodeStmtEcho_ 节点,内容为 "Hello from function!",并使用 array_unshift() 函数将该节点添加到函数体的开头。然后,创建了一个 NodeTraverser 对象,并将 AddEchoStatement 添加到 traverser 中。最后,调用 traverse() 方法遍历 AST,应用修改,并将修改后的 AST 重新生成 PHP 代码。
6.3 删除节点
删除节点需要从其父节点中移除该节点。
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
use PhpParserPrettyPrinterStandard;
class RemoveEchoStatement extends NodeVisitorAbstract {
public function enterNode(Node $node) {
if ($node instanceof NodeStmtEcho_) {
// 从 AST 中删除 echo 语句
return NodeTraverser::REMOVE_NODE;
}
return null;
}
}
$code = <<<'CODE'
<?php
echo "Hello, World!";
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$traverser->addVisitor(new RemoveEchoStatement);
$modifiedAst = $traverser->traverse($ast);
$prettyPrinter = new Standard;
$newCode = $prettyPrinter->prettyPrint($modifiedAst);
echo $newCode; // 输出: <?php
?>
这段代码定义了一个 RemoveEchoStatement 类,实现了 enterNode() 方法。在 enterNode() 方法中,判断节点的类型,如果是 NodeStmtEcho_ 节点,则返回 NodeTraverser::REMOVE_NODE,从而从 AST 中删除该节点。然后,创建了一个 NodeTraverser 对象,并将 RemoveEchoStatement 添加到 traverser 中。最后,调用 traverse() 方法遍历 AST,应用修改,并将修改后的 AST 重新生成 PHP 代码。
7. 静态分析
AST 可以用于进行静态分析,例如:
- 代码风格检查: 检查代码是否符合编码规范。
- 代码复杂度分析: 计算代码的复杂度,例如圈复杂度。
- 安全漏洞检测: 检测代码中是否存在安全漏洞,例如 SQL 注入、XSS 攻击等。
- 类型推断: 推断变量的类型,从而发现类型错误。
- 未使用变量检测: 找到代码中定义了但是没有使用的变量。
7.1 代码风格检查示例
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
class CodeStyleChecker extends NodeVisitorAbstract {
public function enterNode(Node $node) {
if ($node instanceof NodeExprVariable && strlen($node->name) > 10) {
echo "变量名过长: $" . $node->name . "n";
}
return null;
}
}
$code = <<<'CODE'
<?php
$veryLongVariableName = 123;
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$traverser->addVisitor(new CodeStyleChecker);
$traverser->traverse($ast);
?>
这段代码定义了一个 CodeStyleChecker 类,实现了 enterNode() 方法。在 enterNode() 方法中,判断节点的类型,如果是 NodeExprVariable 节点,并且变量名长度大于 10,则输出 "变量名过长"。然后,创建了一个 NodeTraverser 对象,并将 CodeStyleChecker 添加到 traverser 中。最后,调用 traverse() 方法遍历 AST,进行代码风格检查。
7.2 寻找未使用的变量
<?php
require_once 'vendor/autoload.php';
use PhpParserNode;
use PhpParserNodeVisitorAbstract;
use PhpParserNodeTraverser;
use PhpParserParserFactory;
class UnusedVariableChecker extends NodeVisitorAbstract {
private $definedVariables = [];
private $usedVariables = [];
public function enterNode(Node $node) {
if ($node instanceof NodeExprVariable) {
$this->usedVariables[$node->name] = true;
} elseif ($node instanceof NodeStmtExpression && $node->expr instanceof NodeExprAssign) {
$variable = $node->expr->var;
if ($variable instanceof NodeExprVariable) {
$this->definedVariables[$variable->name] = true;
}
}
return null;
}
public function afterTraverse(array $nodes) {
$unusedVariables = array_diff_key($this->definedVariables, $this->usedVariables);
foreach ($unusedVariables as $variableName => $value) {
echo "发现未使用的变量: $" . $variableName . "n";
}
}
}
$code = <<<'CODE'
<?php
$x = 1;
$y = 2;
echo $x;
?>
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser;
$checker = new UnusedVariableChecker();
$traverser->addVisitor($checker);
$traverser->traverse($ast);
// 输出:发现未使用的变量: $y
?>
这段代码演示了如何使用 AST 来检测未使用的变量。 UnusedVariableChecker 维护了两个数组,definedVariables 存储所有定义过的变量,usedVariables 存储所有使用过的变量。在遍历 AST 的过程中,如果遇到赋值语句,则将变量名添加到 definedVariables 数组中,如果遇到变量,则将变量名添加到 usedVariables 数组中。在遍历结束后,使用 array_diff_key() 函数计算 definedVariables 和 usedVariables 的差集,从而找到所有未使用的变量。
8. 高级应用
掌握了 AST 的基本操作后,可以进行一些高级应用,例如:
- 代码重构工具: 自动进行代码重构,例如重命名变量、提取函数、内联函数等。
- 代码生成器: 根据模板和数据生成代码,例如生成 CRUD 接口、生成 ORM 模型等。
- 领域特定语言 (DSL) 解释器: 解释和执行自定义的 DSL 代码。
- AOP (面向切面编程): 在不修改源代码的情况下,动态地添加或修改代码的行为。
9. 总结:AST 是代码理解和操控的基础
我们深入探讨了 PHP AST 操作,并重点介绍了如何利用 nikic/php-parser 这个强大的库进行代码修改和静态分析。AST 作为代码的一种结构化表示,为我们提供了程序理解和操纵的底层基础。通过掌握 AST 的相关技术,我们可以实现诸如代码重构、静态分析、代码生成等高级功能。