入门与实践:用 PHP 解析、分析并改写 PHP 代码)
PHP ParserPHP-Parser入门与实践用 PHP 解析、分析并改写 PHP 代码【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser本文是一份以 PHP-Parsernikic/php-parser官方 README 为主线、结合仓库源码与组件文档的实战指南覆盖从 Composer 安装、把 PHP 源码解析为抽象语法树AST、用 NodeDumper 查看节点结构到通过 NodeTraverser 遍历改写 AST、再经 PrettyPrinter 还原为 PHP 代码的完整闭环。读完本文你将掌握静态代码分析、代码生成与格式化保留改造的基本功能够独立编写自己的 Node Visitor 实现批量代码转换。这是什么一个用 PHP 写成的 PHP 解析器PHP-Parser 是一个用 PHP 本身实现的 PHP 解析器其核心目标是简化静态代码分析与代码操作。它把 PHP 7 / PHP 8 源代码解析为抽象语法树Abstract Syntax TreeAST从而让开发者以抽象、健壮的方式处理代码而不是面对底层的原始 token 流。与 PHP 内置的token_get_all()相比AST 抽象掉了大量语法细节。例如在 PHP 中变量可以写作$foo也可以是$$bar、${foobar}甚至${!${}barfoo()}从 token 流中识别所有这些形式非常繁琐而 AST 会统一抽象为Expr_Variable节点。与此同时token 流保留了精确的格式信息适合做字符级分析AST 则天然适合做结构化分析与改写详见 doc/0_Introduction.markdown。本仓库对应PHP-Parser 5.x主线要求运行环境 PHP 7.4可解析 PHP 7.0 至 PHP 8.4 的代码并对 PHP 5.x 提供有限支持。4.x 分支运行于 PHP 7.0可解析 PHP 5.2 至 PHP 8.3仍受支持本文以当前仓库的 5.x 源码为准。核心功能一览根据 README.md 与源码结构库的主要能力包括解析PHP 7、PHP 8 代码为 AST非法代码也能解析出部分 AST错误恢复且 AST 携带精确的位置信息。人类可读地转储ASTNodeDumper。将 AST 还原为 PHP 代码Pretty Printer对部分改动的 AST 可以保留原有格式。遍历与修改 AST的基础设施NodeTraverser与 Node Visitor。命名空间名称解析NameResolver节点访问器。常量表达式求值ConstExprEvaluator。AST 构建器BuilderFactory用于代码生成场景简化 AST 构造。AST 与 JSON 互转JsonDecoder。这些能力在 lib/PhpParser/ 目录下对应为Parser.php、NodeDumper.php、PrettyPrinter.php、NodeTraverser.php、NodeVisitor/NameResolver.php、ConstExprEvaluator.php、BuilderFactory.php、JsonDecoder.php等实现文件后续章节会逐一深入。快速开始安装、解析、转储安装使用 Composer 安装php composer.phar require nikic/php-parser在代码中引入 Composer 生成的自动加载器即可引导库require path/to/vendor/autoload.php;依赖方面composer.json 要求 PHP 7.4并依赖ext-tokenizer、ext-json、ext-ctype三个扩展autoload采用 PSR-4PhpParser\前缀映射到lib/PhpParser。仓库同时把bin/php-parse注册为可执行脚本见 composer.json安装后可直接在命令行使用。第一个示例解析并转储 AST以下代码把一段函数定义解析为 AST并用NodeDumper输出人类可读的结构?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对应类PhpParser\Node\Stmt\Function_Param、Expr_Variable、Expr_FuncCall、Arg、Name、Scalar_String等依次嵌套array( 0: Stmt_Function( attrGroups: array( ) byRef: false name: Identifier( name: test ) params: array( 0: Param( attrGroups: array( ) flags: 0 type: null byRef: false variadic: false var: Expr_Variable( name: foo ) default: null ) ) returnType: null stmts: array( 0: Stmt_Expression( expr: Expr_FuncCall( name: Name( name: var_dump ) args: array( 0: Arg( name: null value: Expr_Variable( name: foo ) byRef: false unpack: false ) ) ) ) ) ) )从这个输出可以直观看到 AST 的两个特性不含空白/格式信息但大部分注释会被保存同时保留精确的位置信息可供还原格式时使用详见 doc/0_Introduction.markdown。NodeDumper还支持通过构造参数定制输出dumpComments控制是否转储注释dumpPositions控制是否输出行列位置启用时需把原始代码传入dump($node, $code)dumpOtherAttributes控制是否输出注释与位置之外的其他属性。这些选项的实现见 NodeDumper.php。命令行工具 php-parse除了在代码中调用还可以使用php-parse脚本快速查看任意 PHP 代码的 AST 表示这对调试某语法在 AST 中长什么样非常有用vendor/bin/php-parse file.php vendor/bin/php-parse ?php foo();遍历与修改用 NodeTraverser 改写 AST静态分析的核心场景是不知道 AST 长什么样的通用遍历。下面这个例子演示了如何清空所有函数体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); echo $dumper-dump($ast) . \n;遍历后AST 中Function_::$stmts变为空数组array( 0: Stmt_Function( attrGroups: array( ) byRef: false name: Identifier( name: test ) params: array( 0: Param( attrGroups: array( ) flags: 0 type: null byRef: false variadic: false var: Expr_Variable( name: foo ) default: null ) ) returnType: null stmts: array( ) ) )NodeVisitor 接口与返回值语义所有访问器都必须实现PhpParser\NodeVisitor接口其定义见 NodeVisitor.phppublic function beforeTraverse(array $nodes); public function enterNode(\PhpParser\Node $node); public function leaveNode(\PhpParser\Node $node); public function afterTraverse(array $nodes);beforeTraverse()在遍历开始前调用一次可用于重置状态或预处理树返回非 null 时替换被遍历的节点数组。afterTraverse()在遍历结束后调用一次语义同上。enterNode()在进入节点时即子节点被遍历之前调用leaveNode()在离开节点时调用。四个方法返回null表示节点不变返回新节点则替换当前节点。enterNode()/leaveNode()还可返回以下特殊常量均为 NodeVisitor.php 中定义的接口常量返回值含义NodeVisitor::DONT_TRAVERSE_CHILDREN跳过当前节点的所有子节点后续访问器仍会对当前节点调用enterNode/leaveNodeNodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN跳过当前节点的子节点且后续访问器不再访问当前节点NodeVisitor::STOP_TRAVERSAL立即终止整个遍历afterTraverse()仍会被调用NodeVisitor::REMOVE_NODE将当前节点从父数组中移除NodeVisitor::REPLACE_WITH_NULL将当前节点替换为null节点作为数组元素时不合法返回节点数组将数组按当前节点位置合并进父数组例如把array(A, B, C)中的B替换为array(X, Y, Z)得到array(A, X, Y, Z, C)NodeTraverser的实现细节见 NodeTraverser.phptraverse()先依次调用所有访问器的beforeTraverse()随后递归遍历整棵树traverseNode()会遍历每个节点的所有子节点按enterNode→ 递归子节点 →leaveNode的顺序执行最后按逆序调用afterTraverse()。除addVisitor()外还提供removeVisitor()用于动态移除访问器。建议通过继承NodeVisitorAbstract来编写访问器——它提供了上述所有方法的空默认实现只需覆写关心的方法。还原为 PHP 代码PrettyPrinter将新 AST 转换回 PHP 代码use PhpParser\PrettyPrinter; $prettyPrinter new PrettyPrinter\Standard; echo $prettyPrinter-prettyPrintFile($ast);输出结果即为原始代码去掉函数内var_dump()调用后的版本?php function test($foo) { }关于 PrettyPrinter 的关键 API详见 doc/2_Usage_of_basic_components.markdownprettyPrint(array $stmts)打印语句数组。prettyPrintExpr(Node $expr)仅打印单个表达式。prettyPrintFile(array $stmts)按整个文件打印会包含开头的?php标签并更优雅地处理首尾的内联 HTML。格式化保留模式对未改动的 AST 部分保留原始格式但需要额外配置参见 Pretty printing 文档仓库test/code/formatPreservation/下有大量对应测试。Standard类继承自 PrettyPrinterAbstract.php。其中维护了一张运算符优先级表$precedenceMap见 PrettyPrinterAbstract.php涵盖**、一元运算、算术、位运算、逻辑运算、三元、赋值、yield、print、include等全部运算符用于在还原代码时正确插入括号保证语义不变。Standard类本身则对每种节点实现pXxx()打印方法例如pParam()、pArg()、pUnionType()、pAttributeGroup()等见 Standard.php。深入组件一ParserFactory 与目标 PHP 版本解析器实例通过ParserFactory创建选择目标版本是一个关键决策源码见 ParserFactory.phpuse 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()针对当前运行环境的 PHP 版本不使用任何 token 模拟。createForNewestSupportedVersion()针对库支持的最新版本。分析任意代码时通常用它因为它接受最广范围的语法除非存在破坏性变更。createForVersion(PhpVersion)指定精确版本内部PhpVersion::id 80000时选择Php8解析器否则选择Php7解析器非宿主版本会自动使用Lexer\Emulative做 token 模拟。PhpVersion见 PhpVersion.php支持fromComponents($major, $minor)、fromString(8.1)等构造方式getNewestSupported()当前返回 8.4它还维护了一张内置类型的最小版本表如mixed/null/false需要 8.0never需要 8.1true需要 8.2并提供supportsShortArraySyntax()、supportsFlexibleHeredoc()等版本能力判断。解析器实例可复用来解析多个文件。解析失败与错误处理将包含开标签?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等能力可参见 Error handling 文档。解析能力边界在解析范围上需要注意详见 doc/0_Introduction.markdown不支持包含空白的命名空间名称如Foo \ Bar该写法在 PHP 8 中非法且 PHP-Parser 对任何版本都不支持。5.x 对 PHP 5 仅有限支持$$foo[0]这类两种版本解释不同的表达式始终按 PHP 7 语义构造 AST即($$foo)[0]而非${$foo[0]}global $$var[0]形式的声明在 PHP 7 中非法会触发解析错误错误恢复模式下可继续解析。解析器基于token_get_all的 token并通过Lexer\Emulative模拟新版本 token从而允许在 PHP 7.4 上解析 PHP 8.4 源码模拟并非完美但实践中效果良好。设计目标是接受所有合法代码而不是拒绝所有非法代码通常会接受仅在新版本合法的代码也会接受语法正确但会在编译期报错的代码。深入组件二节点体系与位置属性三类节点PHP 语言庞大库中共有大约 140 种节点分为三类详见 doc/2_Usage_of_basic_components.markdownPhpParser\Node\Stmt语句节点不返回值、不能出现在表达式中的语言结构例如类定义Stmt\Class_。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是保留关键字许多节点类名以_结尾以避免冲突。Stmt\Expression节点用来区分exprNode\Expr与expr;表达式语句由Stmt\Expression包裹。所有节点都可通过$node-subNodeName访问子节点例如$stmts[0]-exprs[1]-name每个节点还提供getType()方法返回去掉PhpParser\Node\前缀、\替换为_的类型名。位置信息与自定义属性解析器默认在节点上写入startLine、endLine、startTokenPos、endTokenPos、startFilePos、endFilePos和comments属性comments为PhpParser\Comment[\Doc]实例数组。这些预定义属性可通过getStartLine()、getEndLine()、getStartTokenPos()、getEndTokenPos()、getStartFilePos()、getEndFilePos()、getComments()、getDocComment()等便捷方法直接获取实现见 NodeAbstract.php。其中startLine/endLine默认启用token 与文件偏移属性默认由词法器禁用需要时在词法器选项中开启。自定义元数据可用setAttribute()关联到节点用hasAttribute()、getAttribute()、getAttributes()读取。深入组件三NameResolver 与实战示例NameResolver命名空间名称解析包内预置了一个重要访问器PhpParser\NodeVisitor\NameResolver见 NameResolver.php它尽力把代码中的名称解析为完全限定名。例如use A as B; new B\C();要确定B\C实际是A\C需要自己追踪别名与命名空间而NameResolver会替你完成。它内部维护一个NameContext在enterNode()中处理Namespace_、Use_、GroupUse、Class_、Interface_、Enum_、Trait_、Function_、Const_等各类节点beforeTraverse()时调用startNamespace()初始化上下文。运行后绝大多数名称都会变成完全限定名唯一保持非限定的是非限定的函数名与常量名——它们在运行时才解析访问器无法静态确定通常指全局函数多数场景下无碍。此外NameResolver会给类、函数、常量声明添加namespacedName子节点保存带命名空间前缀的完整名称声明节点本身只有name短名。构造选项包括preserveOriginalNames为被解析过的名称节点附加originalName属性与replaceNodes默认为 true 原地替换设为 false 则改为附加resolvedName属性。详见 Name resolution 文档。实战示例把命名空间代码转换为伪命名空间下面的完整示例演示了分析/改写类工具的标准流水线parse → traverse多个 visitor→ prettyPrintFile目标是把A\B风格的命名空间代码转换为A_B风格假定不使用动态特性使转换可行。基架如下详见 doc/2_Usage_of_basic_components.markdownuse PhpParser\ParserFactory; use PhpParser\PrettyPrinter; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; $inDir /some/path; $outDir /some/other/path; $parser (new ParserFactory())-createForNewestSupportedVersion(); $traverser new NodeTraverser; $prettyPrinter new PrettyPrinter\Standard; $traverser-addVisitor(new NameResolver); // we will need resolved names $traverser-addVisitor(new NamespaceConverter); // our own node visitor // iterate over all .php files in the directory $files new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($inDir)); $files new \RegexIterator($files, /\.php$/); foreach ($files as $file) { try { // read the file that should be converted $code file_get_contents($file-getPathName()); // parse $stmts $parser-parse($code); // traverse $stmts $traverser-traverse($stmts); // pretty print $code $prettyPrinter-prettyPrintFile($stmts); // write the converted file to the target directory file_put_contents( substr_replace($file-getPathname(), $outDir, 0, strlen($inDir)), $code ); } catch (PhpParser\Error $e) { echo Parse Error: , $e-getMessage(); } }核心转换访问器分三步完成1) 转换名称节点——得益于NameResolver已把名称尽量解析只需把反斜杠替换为下划线并返回新节点替换旧节点use PhpParser\Node; class NamespaceConverter extends \PhpParser\NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Name) { return new Node\Name(str_replace(\\, _, $node-toString())); } } }2) 改写类/接口/函数/常量声明——它们目前只有短名需要补全命名空间前缀$node-namespacedName由NameResolver提供use PhpParser\Node; use PhpParser\Node\Stmt; class NodeVisitor_NamespaceConverter extends \PhpParser\NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Name) { return new Node\Name(str_replace(\\, _, $node-toString())); } elseif ($node instanceof Stmt\Class_ || $node instanceof Stmt\Interface_ || $node instanceof Stmt\Function_) { $node-name str_replace(\\, _, $node-namespacedName-toString()); } elseif ($node instanceof Stmt\Const_) { foreach ($node-consts as $const) { $const-name str_replace(\\, _, $const-namespacedName-toString()); } } } }3) 移除namespace与use语句——返回$node-stmts会把Namespace_的语句数组合并进父数组返回NodeVisitor::REMOVE_NODE则把Use_节点整体删除use PhpParser\Node; use PhpParser\Node\Stmt; use PhpParser\NodeVisitor; class NodeVisitor_NamespaceConverter extends \PhpParser\NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Name) { return new Node\Name(str_replace(\\, _, $node-toString())); } elseif ($node instanceof Stmt\Class_ || $node instanceof Stmt\Interface_ || $node instanceof Stmt\Function_) { $node-name str_replace(\\, _, $node-namespacedName-toString()); } elseif ($node instanceof Stmt\Const_) { foreach ($node-consts as $const) { $const-name str_replace(\\, _, $const-namespacedName-toString()); } } elseif ($node instanceof Stmt\Namespace_) { // returning an array merges is into the parent array return $node-stmts; } elseif ($node instanceof Stmt\Use_) { // remove use nodes altogether return NodeVisitor::REMOVE_NODE; } } }这个示例同时印证了上一节的两类返回值语义返回节点数组进行展开合并返回REMOVE_NODE进行删除。性能注意事项关于运行环境的两个重要建议详见 2_Usage_of_basic_components.markdown 与 Performance 文档尽量完全禁用 Xdebug——Xdebug 可能让本库慢五倍以上若无法禁用可调高嵌套层级避免深层节点树遍历时报错ini_set(xdebug.max_nesting_level, 3000);复用对象——解析器实例、遍历器等重量级对象应复用避免重复构造带来的开销。更多组件与文档索引除本文覆盖的核心组件外仓库还提供以下专项文档见 README.md 与 doc/README.md 的完整索引Walking the AST节点访问器进阶修改 AST、短路遍历、交错访问器、简单节点查找 API、父节点与兄弟节点引用。Name resolution名称解析选项与解析上下文。Pretty printingAST 还原、自定义格式、格式化保留转换。AST buildersBuilderFactory流式构建 AST 节点。Lexertoken 模拟、token/位置/属性。Error handling错误列信息与错误恢复。Constant expression evaluationConstExprEvaluator求值常量/属性初始化器。JSON representationAST 的 JSON 编解码JsonDecoder。Performance禁用 Xdebug、对象复用、垃圾回收影响。FAQ常见问题含父节点与兄弟节点引用。入门路径建议为先读 Introduction 与 Usage of basic components再按需深入上述组件文档。总结通过本文你应该已经掌握 PHP-Parser 的完整工作闭环用ParserFactory按目标版本创建解析器 →parse()得到 AST →NodeDumper观察结构 → 用NodeTraverser 自定义 Visitor 分析/改写 → 用PrettyPrinter\Standard还原为 PHP 代码并理解了节点三类体系、位置属性、NodeVisitor返回值语义、NameResolver名称解析等关键机制。这一套解析-遍历-打印范式是 PHP 静态分析工具代码质量检查、自动化重构、代码生成、脚手架搭建等的共同基石仓库 test/ 目录下的海量测试与 test/code/ 中的.test用例含 formatPreservation 格式化保留测试可作为继续深入的最佳参考。【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考