PHP-Parser 入门指南:用 PHP 解析 PHP 代码并构建抽象语法树(AST)
【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser
本文面向希望以编程方式分析、修改 PHP 源码的开发者,系统介绍 PHP-Parser 这个"用 PHP 编写的 PHP 解析器"的定位、能力边界、AST 输出形态,以及解析、转储、遍历与打印输出的核心用法。读完本文,你将能够把 PHP 7/8 代码解析为抽象语法树(AST),读懂节点转储输出,并通过节点遍历器与访问器对代码进行结构化分析和改写。
为什么需要一个用 PHP 写的 PHP 解析器
PHP-Parser 的核心定位在 doc/0_Introduction.markdown 中开宗明义:它是一个用 PHP 自身编写的 PHP 解析器。解析器的价值在于为静态分析、代码操纵以及任何需要以编程方式处理代码的场景提供基础能力——它把源码构造成抽象语法树(AST),从而允许我们以抽象、健壮的方式处理代码。
与 token_get_all 的对比
PHP 本身也提供了一种处理源码的方式:token_get_all()返回的 token 流。两者各有适用场景:
- token 流更底层:它保留了文件的精确格式信息,适合做需要逐字符分析的场景。
- AST 更抽象:它把语言结构统一为树形节点。例如,PHP 中变量既可以写成
$foo,也可以写成$$bar、${'foobar'}甚至${!${''}=barfoo()},AST 会将这些不同的语法统一表示为"变量"节点,你完全不必从 token 流中识别所有这些写法。
为什么用 PHP 写解析器
PHP 可能不是最适合高速解析的语言,但处理 AST 的工作在 PHP 中远比在 C 等更快的语言中容易;更重要的是,最可能需要做程序化 PHP 代码分析的人,恰恰是 PHP 开发者,而不是 C 开发者。这就是 PHP-Parser 选择"用 PHP 写解析器"的根本原因。
它能解析什么:版本支持范围与边界
根据文档与 README.md 的说明,PHP-Parser 5.x 支持解析PHP 7 与 PHP 8 代码,并且:
- 命名空间名含空白(如
Foo \ Bar而非Foo\Bar)不被支持。这类写法在 PHP 8 中非法,但在早期版本中合法;PHP-Parser 对任何版本都不支持它。 - PHP 5 支持有限:PHP-Parser 4.x 完整支持 PHP 5,而 5.x 仅保留有限支持,具体限制包括:
- 某些变量表达式(如
$$foo[0])在 PHP 5 与 PHP 7 中都合法但解释不同,此时总是构造 PHP 7 的 AST(即($$foo)[0]而非${$foo[0]})。 global $$var[0]形式的声明在 PHP 7 中不支持,会引发解析错误;在错误恢复模式下可以跳过这类声明继续解析。
- 某些变量表达式(如
面向未来版本的 token 模拟
由于解析器基于token_get_all()返回的 token(它只能对运行环境所在的 PHP 版本进行词法分析),库额外提供了一个token 模拟(emulation)包装器:例如可以在 PHP 7.4 上运行,却解析 PHP 8.4 的源码。这种模拟并非完美,但在实践中工作良好。相关实现位于 lib/PhpParser/Lexer/Emulative.php 及其 TokenEmulator 子目录。
"接受所有合法代码"的设计哲学
值得注意的还有一点:解析器的目标是接受所有合法代码,而不是拒绝所有非法代码。它通常会接受仅在更新版本中合法的代码(即使你指定解析较老版本),也会接受语法正确但会产生编译错误的代码。
输出什么:抽象语法树(AST)的样子
解析器输出的是一棵抽象语法树,也称作节点树。以文档中的例子,程序<?php echo 'Hi', 'World';会得到大致如下的节点树:
array( 0: Stmt_Echo( exprs: array( 0: Scalar_String( value: Hi ) 1: Scalar_String( value: World ) ) ) )这恰好对应代码的结构:一条 echo 语句,携带两个字符串表达式,值分别为Hi和World。
从转储中可以看出两个重要特性:
- AST 不包含空白/格式信息(但大多数注释会被保留);
- AST 保留精确的位置信息(行号、文件偏移、token 位置),可用于检查精确的格式。
这些位置信息对应 NodeAbstract.php 中默认启用的startLine/endLine属性,以及默认禁用、需在词法器配置中开启的startTokenPos/endTokenPos/startFilePos/endFilePos属性。
快速上手:解析一段代码并转储 AST
在 README.md 的 Quick Start 中给出了最典型的入门路径。首先用 Composer 安装:
php composer.phar require nikic/php-parser然后解析代码并转储结果:
<?php use PhpParser\Error; use PhpParser\NodeDumper; use PhpParser\ParserFactory; $code = <<<'CODE' <?php function test($foo) { var_dump($foo); } CODE; $parser = (new ParserFactory())->createForNewestSupportedVersion(); try { $ast = $parser->parse($code); } catch (Error $error) { echo "Parse error: {$error->getMessage()}\n"; return; } $dumper = new NodeDumper; echo $dumper->dump($ast) . "\n";输出是一个人类可读的节点转储,可以看到Stmt_Function、Param、Expr_Variable、Expr_FuncCall、Arg等节点层层嵌套。
用 php-parse 命令行脚本快速查看 AST
doc/2_Usage_of_basic_components.markdown 提到,不必写代码也能查看 AST:直接使用随库分发的php-parse脚本,传文件名或代码字符串即可:
vendor/bin/php-parse file.php vendor/bin/php-parse "<?php foo();"当你想快速确认某种语法在 AST 中如何表示时,这个脚本非常有用。
Parser 接口:parse() 与 getTokens()
底层解析行为由 Parser 接口 定义,核心方法为:
public function parse(string $code, ?ErrorHandler $errorHandler = null): ?array; public function getTokens(): array;parse()返回语句节点数组(Node\Stmt[]);当使用非抛异常的错误处理器且无法从错误中恢复时返回null。getTokens()则返回最近一次解析的 token 数组,可用于后续的格式分析。
理解节点树结构:三类核心节点
doc/2_Usage_of_basic_components.markdown 对节点树结构做了系统讲解。PHP 是庞大的语言,因此约有 140 种不同的节点,它们被归为三类(对应 lib/PhpParser/Node 目录下的子命名空间):
PhpParser\Node\Stmt语句节点:不返回值、不能出现在表达式中的语言结构。例如类定义是语句——它不返回值,你无法写出func(class A {});这种代码。PhpParser\Node\Expr表达式节点:返回值、可以出现在其他表达式中的结构,例如$var(Expr\Variable)和func()(Expr\FuncCall)。PhpParser\Node\Scalar标量节点:表示标量值,如'string'(Scalar\String_)、0(Scalar\LNumber)或__FILE__等魔术常量(Scalar\MagicConst\File)。所有Scalar都继承自Expr,因为标量本身也是表达式。- 此外还有一些不属于上述任何类别的节点,例如名称(
Node\Name)和调用参数(Node\Arg)。
节点命名与子节点访问
从转储输出可以看出,节点类名带_后缀(如Stmt_Function -> PhpParser\Node\Stmt\Function_),这是为了避开Function等保留关键字——库中许多节点类名都有尾随下划线。getType()方法返回节点类型,即去掉PhpParser\Node\前缀、把\替换为_的类名。
每个节点有零个或多个子节点,通过$node->subNodeName访问。例如Stmt\Echo_只有一个子节点exprs,要访问上面示例中的函数名可以写$stmts[0]->exprs[1]->name。
节点属性:位置信息与自定义元数据
节点可以通过setAttribute()关联自定义元数据,用hasAttribute()、getAttribute()、getAttributes()读取。默认情况下解析器会添加startLine、endLine、startTokenPos、endTokenPos、startFilePos、endFilePos和comments属性(comments是PhpParser\Comment[\Doc]实例数组)。
预定义属性也可以直接用便捷方法访问,例如getStartLine()等价于getAttribute('startLine');getDocComment()返回comments属性中最后一个文档注释。
遍历与修改 AST:NodeTraverser 与 NodeVisitor
上面"直接按下标访问已知节点"的方式只适合源码已知的场景。通常我们需要以通用方式遍历整棵节点树,这正是PhpParser\NodeTraverser与NodeVisitor的用武之地。README.md 给出了一个"清空所有函数体"的示例:
use PhpParser\Node; use PhpParser\Node\Stmt\Function_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; $traverser = new NodeTraverser(); $traverser->addVisitor(new class extends NodeVisitorAbstract { public function enterNode(Node $node) { if ($node instanceof Function_) { // Clean out the function body $node->stmts = []; } } }); $ast = $traverser->traverse($ast);NodeVisitor 接口的四个回调方法
所有访问器都必须实现 NodeVisitor 接口,它定义了四个方法:
public function beforeTraverse(array $nodes); public function enterNode(\PhpParser\Node $node); public function leaveNode(\PhpParser\Node $node); public function afterTraverse(array $nodes);beforeTraverse()在遍历开始前调用一次,可用于重置状态或准备树;afterTraverse()在遍历结束后调用一次;enterNode()在进入每个节点(即遍历其子节点之前)时调用;leaveNode()在离开每个节点时调用。
四个方法都可以返回替换后的节点或不返回(null,表示节点不变)。此外还支持一系列特殊返回值常量,见 NodeVisitor.php 的接口定义:
| 常量 | 含义 |
|---|---|
NodeVisitor::DONT_TRAVERSE_CHILDREN | 跳过当前节点的所有子节点 |
NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN | 同时阻止后续访问器访问当前节点及其子节点 |
NodeVisitor::STOP_TRAVERSAL | 终止遍历,不再访问任何节点 |
NodeVisitor::REMOVE_NODE | 将当前节点从父数组中移除 |
NodeVisitor::REPLACE_WITH_NULL | 将当前节点替换为null |
| 返回节点数组 | 将数组合并进父数组的当前位置(如array(A, B, C)中把B替换为array(X, Y, Z)后得到array(A, X, Y, Z, C)) |
与其手动实现NodeVisitor接口,更常见的做法是继承 NodeVisitorAbstract,它提供了上述方法的空默认实现,只需覆写关心的回调即可。
综合示例:解析 → 遍历 → 打印
doc/2_Usage_of_basic_components.markdown 给出了一个完整的"读文件、解析、遍历、回写"骨架:
use PhpParser\NodeTraverser; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter; $parser = (new ParserFactory())->createForHostVersion(); $traverser = new NodeTraverser; $prettyPrinter = new PrettyPrinter\Standard; // add your visitor $traverser->addVisitor(new MyNodeVisitor); try { $code = file_get_contents($fileName); $stmts = $parser->parse($code); $stmts = $traverser->traverse($stmts); $code = $prettyPrinter->prettyPrintFile($stmts); echo $code; } catch (PhpParser\Error $e) { echo 'Parse Error: ', $e->getMessage(); }对应的访问器示例——把程序中所有字符串字面量改成'foo':
use PhpParser\Node; use PhpParser\NodeVisitorAbstract; class MyNodeVisitor extends NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Scalar\String_) { $node->value = 'foo'; } } }解析器的三个工厂方法:如何选择目标版本
要创建解析器实例,使用 ParserFactory。它提供了三个工厂方法:
use PhpParser\ParserFactory; use PhpParser\PhpVersion; // Parser for the version you are running on. $parser = (new ParserFactory())->createForHostVersion(); // Parser for the newest PHP version supported by the PHP-Parser library. $parser = (new ParserFactory())->createForNewestSupportedVersion(); // Parser for a specific PHP version. $parser = (new ParserFactory())->createForVersion(PhpVersion::fromString('8.1'));createForHostVersion():不启用任何 token 模拟,直接针对运行环境版本;createForNewestSupportedVersion():针对库支持的最新版本(当前为 PHP 8.4,见 PhpVersion.php 中的getNewestSupported()),只要没有破坏性变更就接受旧代码;createForVersion(PhpVersion $version):指定目标版本;当目标版本不是宿主版本时,会自动使用Lexer\Emulative做 token 模拟,且根据版本选择 Php7 或 Php8 解析器(版本 id >= 80000 用 Php8)。
如何选择:很多时候人们分析的就是自己运行环境上的代码,用宿主版本即可;但分析任意代码时,通常最适合用最新支持版本,因为它接受的代码范围最广(除非 PHP 发生了破坏性变更)。createXYZ()方法还可以可选地接收词法器选项数组,自定义词法行为详见 Lexer 文档。
解析时把 PHP 代码(包含开头的<?php标签)传给parse()方法。默认遇到语法错误会抛出PhpParser\Error异常:
<?php use PhpParser\Error; use PhpParser\ParserFactory; $code = <<<'CODE' <?php function printLine($msg) { echo $msg, "\n"; } printLine('Hello World!!!'); CODE; $parser = (new ParserFactory())->createForHostVersion(); try { $stmts = $parser->parse($code); // $stmts is an array of statement nodes } catch (Error $e) { echo 'Parse Error: ', $e->getMessage(), "\n"; }一个解析器实例可以被复用去解析多个文件。
把 AST 打印回 PHP 代码:PrettyPrinter
解析、修改之后,往往还需要把 AST 转换回 PHP 源码,这就是 pretty printer(美化打印器)的职责。文档特别提醒:"pretty printing"并不意味着输出特别漂亮,这只是它的叫法。目前只有一种打印方案:PhpParser\PrettyPrinter\Standard。
use PhpParser\Error; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter; $code = "<?php echo 'Hi ', hi\\getTarget();"; $parser = (new ParserFactory())->createForHostVersion(); $prettyPrinter = new PrettyPrinter\Standard(); try { // parse $stmts = $parser->parse($code); // change $stmts[0] // the echo statement ->exprs // sub expressions [0] // the first of them (the string node) ->value // it's value, i.e. 'Hi ' = 'Hello '; // change to 'Hello ' // pretty print $code = $prettyPrinter->prettyPrint($stmts); echo $code; } catch (Error $e) { echo 'Parse Error: ', $e->getMessage(), "\n"; }输出为echo 'Hello ', hi\getTarget();。整个流程是:先用Parser->parse()解析源码,修改节点,再用PrettyPrinter\Standard->prettyPrint()打印回代码。
打印器提供三个入口方法:
prettyPrint($stmts):打印语句数组;prettyPrintExpr($expr):只打印单个表达式;prettyPrintFile($stmts):打印整个文件,会包含开头的<?php标签,并更优雅地处理作为首尾语句的内联 HTML。
此外还有一种保留格式的打印模式,可以对未修改的 AST 部分保留原始格式,但需要额外的设置,详见 Pretty printing 文档。
内置的 NameResolver:命名空间名称解析
包内还内置了一个开箱即用的访问器PhpParser\NodeVisitor\NameResolver,它帮助处理命名空间代码,把大多数名称解析为完全限定名。
例如考虑如下代码:
use A as B; new B\C();要知道B\C实际上是A\C,你需要自己跟踪别名和命名空间;NameResolver负责处理这些,并尽可能解析名称。运行后大多数名称会成为完全限定名,唯一保持非限定的名称是非限定的函数名和常量名——它们在运行时才解析,访问器无法得知指向哪个函数(多数情况下这不成问题,因为通常指的是全局函数)。
此外,NameResolver会给类、函数和常量声明添加一个namespacedName子节点,包含带命名空间前缀的完整名称(而name只有短名)。更多细节见 Name resolution 文档。
实战:把命名空间代码转换为伪命名空间
doc/2_Usage_of_basic_components.markdown 给出了一个完整的综合示例:把命名空间代码转换为A\\B→A_B形式的伪命名空间(假设不使用动态特性)。思路是组合NameResolver与自定义NamespaceConverter两个访问器:
- 第一个访问器
NameResolver预先解析所有名称; - 第二个访问器
NamespaceConverter在leaveNode()中完成三件事:- 把
Node\Name中的\替换为_(str_replace('\\', '_', $node->toString())),并返回新节点以替换旧节点; - 把类/接口/函数声明的
name替换为namespacedName转换后的完整名称; - 对
Stmt\Namespace_返回$node->stmts(返回数组会合并进父数组,从而"拆掉" namespace 层),对Stmt\Use_返回NodeVisitor::REMOVE_NODE直接删除 use 语句。
- 把
主循环则遍历目录下所有.php文件,依次执行"读文件 → 解析 → 遍历 → 打印 → 写回"。
除此之外:库还附带哪些相关能力
除了解析器本身,包内还打包了若干相关功能(README.md 的 Features 列表与 Introduction 文档均有提及):
- pretty printing:把 AST 转换回 PHP 代码;
- JSON 序列化/反序列化:把节点树编码为 JSON 及还原(见 JSON representation 文档);
- 人类可读的节点转储:即上文展示的输出形态(NodeDumper.php 实现,支持
dumpComments、dumpPositions、dumpOtherAttributes等选项); - 遍历与修改 AST 的基础设施:节点遍历器与访问器;
- 名称解析访问器:解析命名空间名称。
使用前的环境建议
doc/2_Usage_of_basic_components.markdown 在 Bootstrapping 一节给出两条实用建议:
- 通过 Composer 生成的 autoloader 引入库:
require 'path/to/vendor/autoload.php'; - 如开启 Xdebug,可把
xdebug.max_nesting_level调到更高值(例如 3000)以避免遍历深度嵌套节点树时报错;但最好完全禁用 Xdebug,因为它可能让本库变慢五倍以上。相关性能话题在 Performance 文档 中有进一步讨论。
延伸阅读
- 完整入门教程:Usage of basic components
- AST 遍历与访问器:Walking the AST
- 名称解析:Name resolution
- 打印回代码与格式保留:Pretty printing
- 词法器与 token 模拟:Lexer
- 错误处理与错误恢复:Error handling
- JSON 表示:JSON representation
- 常见问题:FAQ
【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考