diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 07704c9fe7c..b2252cdc4eb 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -175,6 +175,7 @@ use function array_slice; use function array_values; use function count; +use function get_class; use function in_array; use function is_array; use function is_int; @@ -197,6 +198,19 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; + /** + * Memoizes which ExprHandler handles each Expr class so processExprNode() does not + * re-scan every tagged handler on each call. Keyed by Expr class-string; false means + * no handler matched (default handling). + * + * Call-likes (Expr\CallLike) are never memoized: their handlers select on + * isFirstClassCallable(), which the Expr class alone does not determine. Every other + * handler's supports() is a pure instanceof check, so the class fully determines it. + * + * @var array, ExprHandler|false> + */ + private array $exprHandlersByClass = []; + /** @var array */ private array $earlyTerminatingMethodNames; @@ -2767,12 +2781,8 @@ public function processExprNode( $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); - /** @var ExprHandler $exprHandler */ - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { - if (!$exprHandler->supports($expr)) { - continue; - } - + $exprHandler = $this->resolveExprHandler($expr); + if ($exprHandler !== null) { return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); } @@ -2792,6 +2802,40 @@ public function processExprNode( ); } + /** + * Resolves the ExprHandler for the given expression, memoizing the result by Expr class. + * + * @return ExprHandler|null + */ + private function resolveExprHandler(Expr $expr): ?ExprHandler + { + // Call-likes are excluded from the cache: their handlers select on + // isFirstClassCallable(), so the Expr class does not uniquely determine the handler. + if (!$expr instanceof Expr\CallLike) { + $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; + if ($cached !== null) { + return $cached === false ? null : $cached; + } + } + + $matchedHandler = null; + /** @var ExprHandler $exprHandler */ + foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { + if (!$exprHandler->supports($expr)) { + continue; + } + + $matchedHandler = $exprHandler; + break; + } + + if (!$expr instanceof Expr\CallLike) { + $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; + } + + return $matchedHandler; + } + /** * @param 'get'|'set' $hookName * @return InternalThrowPoint[] diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f0..73c56dbf23d 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -44,6 +44,7 @@ use function array_map; use function array_merge; use function count; +use function get_class; use function in_array; use function strtolower; use function substr; @@ -59,6 +60,20 @@ final class TypeSpecifier /** @var StaticMethodTypeSpecifyingExtension[][]|null */ private ?array $staticMethodTypeSpecifyingExtensionsByClass = null; + /** + * Memoizes which ExprHandler handles each Expr class so specifyTypesInCondition() + * does not re-scan every tagged handler (a linear supports() sweep) on each call. + * This matters for deep boolean chains, where specifyTypesInCondition() runs once per arm. + * Keyed by Expr class-string; false means no handler matched (default narrowing). + * + * Call-likes (Expr\CallLike) are never memoized: their handlers select on + * isFirstClassCallable(), which the Expr class alone does not determine. Every other + * handler's supports() is a pure instanceof check, so the class fully determines it. + * + * @var array, ExprHandler|false> + */ + private array $exprHandlersByClass = []; + /** * @param FunctionTypeSpecifyingExtension[] $functionTypeSpecifyingExtensions * @param MethodTypeSpecifyingExtension[] $methodTypeSpecifyingExtensions @@ -89,16 +104,46 @@ public function specifyTypesInCondition( return (new SpecifiedTypes([], []))->setRootExpr($expr); } + $exprHandler = $this->resolveExprHandler($expr); + if ($exprHandler !== null) { + return $exprHandler->specifyTypes($this, $scope, $expr, $context); + } + + return $this->specifyDefaultTypes($scope, $expr, $context); + } + + /** + * Resolves the ExprHandler for the given expression, memoizing the result by Expr class. + * + * @return ExprHandler|null + */ + private function resolveExprHandler(Expr $expr): ?ExprHandler + { + // Call-likes are excluded from the cache: their handlers select on + // isFirstClassCallable(), so the Expr class does not uniquely determine the handler. + if (!$expr instanceof Expr\CallLike) { + $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; + if ($cached !== null) { + return $cached === false ? null : $cached; + } + } + + $matchedHandler = null; /** @var ExprHandler $exprHandler */ foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($expr)) { continue; } - return $exprHandler->specifyTypes($this, $scope, $expr, $context); + $matchedHandler = $exprHandler; + break; } - return $this->specifyDefaultTypes($scope, $expr, $context); + if (!$expr instanceof Expr\CallLike) { + $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; + } + + return $matchedHandler; } /** @internal */