PHP-Parser 在 enterNode 中替换节点为什么会无限递归?怎么避免

发布时间:2026/9/14 13:45:53
PHP-Parser 在 enterNode 中替换节点为什么会无限递归?怎么避免 PHP-Parser 在 enterNode 中替换节点为什么会无限递归怎么避免【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser在基于 nikic/php-parser下文简称 PHP-Parser编写自定义节点访问器NodeVisitor做 AST 转换时返回一个新节点来替换当前节点是最常用的写法。但官方文档明确提醒如果这个替换发生在enterNode()中且新节点又包住了原节点递归遍历会不断进入新节点的子节点可能陷入无限递归脚本不会结束直到 PHP 达到内存上限。本文按文档给出的最小例子讲清楚原因并给出把替换挪到正确阶段的修正写法让遍历能正常结束。适用环境PHP-Parser 5.x 文档版本运行环境要求 PHP 7.4该版本可解析 PHP 7.0 至 8.4 的代码。背景enterNode 与 leaveNode 的调用时机不同PhpParser\NodeVisitor接口定义了四个回调beforeTraverse()、enterNode()、leaveNode()、afterTraverse()。与这个问题直接相关的是后两个enterNode()在节点第一次被遇到、其子节点被处理之前调用preorderleaveNode()在一个节点的所有子节点都访问完之后调用postorder。文档给出了一个具体的调用顺序。对下面这段 AST 摘录Expr_FuncCall( name: Name( name: printLine ) args: array( 0: Arg( name: null value: Scalar_String( value: Hello World!!! ) byRef: false unpack: false ) ) )enter/leave 方法的调用顺序为enterNode(Expr_FuncCall) enterNode(Name) leaveNode(Name) enterNode(Arg) enterNode(Scalar_String) leaveNode(Scalar_String) leaveNode(Arg) leaveNode(Expr_FuncCall)关键在于当访问器在enterNode()里返回一个新节点替换当前节点后traverser 会继续递归遍历新节点的子节点。这一点在 NodeTraverser.php 的traverseNode()实现里可以直接看到enterNode 返回的替换节点被写回 AST 之后代码立即对该替换节点调用traverseNode()而 leaveNode 的返回值替换发生在子节点遍历完成之后新节点不会再进入子节点遍历阶段。如果你不想自己实现全部四个方法建议继承NodeVisitorAbstract它为每个方法提供了空的默认实现。为什么在 enterNode 中做包装式替换会无限递归文档使用的例子是把所有$a $bNode\Expr\BinaryOp\BooleanAnd表达式转换成!($a $b)即用Node\Expr\BooleanNot包一层。BooleanNot只有一个子节点expr见 BooleanNot.php 的getSubNodeNames()。如果替换发生在enterNodepublic function enterNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { // Convert all $a $b expressions into !($a $b) return new Node\Expr\BooleanNot($node); } }执行过程是traverser 遇到$a $benterNode返回!($a $b)完成替换由于替换发生在enterNodetraverser 接着进入!($a $b)的第一个也是唯一一个子节点而它正是原来的$a $b节点该节点再次命中BooleanAnd条件再包一层变成!!($a $b)第 2 步重复!逐层叠加。文档的原话是This will continue until PHP hits the memory limit. 也就是说这个问题的现象是转换脚本无法正常返回进程一直运行、内存持续增长最终可能因达到内存上限而中止。文档对这一行为的表述是替换在enterNode和leaveNode里都受支持但必须清楚替换发生的位置——If a node is replaced in enterNode, then the recursive traversal will also consider the children of the new node. If you arent careful, this can lead to infinite recursion.修复把替换挪到 leaveNode官方文档给出的修正做法是把同样的替换逻辑放到leaveNode()中public function leaveNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { // Convert all $a $b expressions into !($a $b) return new Node\Expr\BooleanNot($node); } }关键在于时机leaveNode()被调用时当前节点内部的所有代码都已经被访问过包出来的!($a $b)不会被重新遍历转换对原来的$a $b只应用一次遍历随即正常结束。文档同时指出一个常见模式用enterNode()收集信息用leaveNode()基于这些信息做修改——At the time when leaveNode is called, all the code inside the node will have already been visited and necessary information collected.一个容易混淆的点NodeVisitor::DONT_TRAVERSE_CHILDREN不能用来解决本问题。按 NodeVisitor.php 的定义返回它的语义是跳过当前节点的子节点且 $node stays as-is节点本身不被替换所以它无法与返回新节点做替换同时生效而且它只能在enterNode()中使用走到leaveNode()时已经太迟。它适合的是找到节点后跳过其子树的场景例如收集所有类声明时PHP 不允许嵌套类找到Node\Stmt\Class_后不必再进入其子节点而不是替换节点。复现问题并验证修复效果先用 composer 安装库仓库 README 给出的命令php composer.phar require nikic/php-parser下面是一个完整脚本使用修正后替换在leaveNode中的访问器。代码中的path/to/vendor/autoload.php需替换为你自己项目中 composer autoload 的真实路径该占位符来自基本组件使用文档?php require path/to/vendor/autoload.php; use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter; $code CODE ?php echo $a $b; CODE; $parser (new ParserFactory())-createForHostVersion(); $stmts $parser-parse($code); $traverser new NodeTraverser(); $traverser-addVisitor(new class extends NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { return new Node\Expr\BooleanNot($node); } } }); $stmts $traverser-traverse($stmts); echo (new PrettyPrinter\Standard())-prettyPrintFile($stmts);验证方式修正版traverse()正常返回输出的代码中$a $b被包成单层!($a $b)文档示例的转换目标即 Convert all $a $b expressions into !($a $b)。复现原问题把leaveNode改回enterNode即上一节为什么无限递归中的包装式替换代码再运行脚本会持续运行不终止!逐层叠加直到 PHP 达到内存上限。createForHostVersion()创建针对当前运行 PHP 版本的解析器如果分析的是任意来源的代码文档建议改用createForNewestSupportedVersion()它接受范围通常最宽见基本组件使用文档。边界与已知限制在enterNode中替换节点是库支持的合法操作问题只出在新节点的子节点会再次命中转换条件。凡是包一层式的替换原节点成为新节点的子节点都应优先放到leaveNode()中执行。替换的节点种类有限制NodeTraverser的ensureReplacementReasonable()方法见 NodeTraverser.php会在你用表达式Node\Expr替换语句Node\Stmt时抛出LogicException错误信息提示检查是否缺少Stmt_Expression包装。遍历高度嵌套的节点树时文档建议将xdebug.max_nesting_levelini 选项调高文档示例为ini_set(xdebug.max_nesting_level, 3000);并更推荐完全禁用 Xdebug因为它可能让该库慢五倍以上。多个访问器交替执行的规则、REMOVE_NODE、REPLACE_WITH_NULL、STOP_TRAVERSAL等返回值的完整语义都在Walking the AST中有说明遇到更复杂的替换需求时对照该文档处理。【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考