)
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 命令行脚本快速查看 ASTdoc/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表达式节点返回值、可以出现在其他表达式中的结构例如$varExpr\Variable和func()Expr\FuncCall。PhpParser\Node\Scalar标量节点表示标量值如stringScalar\String_、0Scalar\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属性中最后一个文档注释。遍历与修改 ASTNodeTraverser 与 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(); }对应的访问器示例——把程序中所有字符串字面量改成foouse 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 // its 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 componentsAST 遍历与访问器Walking the AST名称解析Name resolution打印回代码与格式保留Pretty printing词法器与 token 模拟Lexer错误处理与错误恢复Error handlingJSON 表示JSON representation常见问题FAQ【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考