From 507507fc90da69de0d5d71cf2c78e43428621136 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sat, 4 Jul 2026 16:26:09 +0200 Subject: [PATCH 1/3] [TypeDeclaration] Add AddClosureParamTypeFromVariableCallRector Infer a closure param's object type from the argument the assigned closure variable is invoked with, e.g. $cb($formView) where $formView is a FormView adds a FormView type hint to the closure param. Only concrete object types are added; scalar/array/conflicting args are skipped. --- ...ureParamTypeFromVariableCallRectorTest.php | 28 +++ .../Fixture/object_param.php.inc | 39 ++++ .../Fixture/recursive_form_view.php.inc | 41 ++++ .../Fixture/skip_already_typed.php.inc | 17 ++ .../Fixture/skip_conflicting_types.php.inc | 19 ++ .../Fixture/skip_scalar_arg.php.inc | 15 ++ .../Source/FormView.php | 29 +++ .../Source/Item.php | 10 + .../Source/Other.php | 9 + .../config/configured_rule.php | 9 + ...ClosureParamTypeFromVariableCallRector.php | 221 ++++++++++++++++++ 11 files changed, 437 insertions(+) create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/AddClosureParamTypeFromVariableCallRectorTest.php create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/object_param.php.inc create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/recursive_form_view.php.inc create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_already_typed.php.inc create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_conflicting_types.php.inc create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_scalar_arg.php.inc create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/FormView.php create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/Item.php create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/Other.php create mode 100644 rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/config/configured_rule.php create mode 100644 rules/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector.php diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/AddClosureParamTypeFromVariableCallRectorTest.php b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/AddClosureParamTypeFromVariableCallRectorTest.php new file mode 100644 index 00000000000..1115f0803f1 --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/AddClosureParamTypeFromVariableCallRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/object_param.php.inc b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/object_param.php.inc new file mode 100644 index 00000000000..9bddd73fae7 --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/object_param.php.inc @@ -0,0 +1,39 @@ +name; + }; + + $printItem(new Item()); + } +} + +?> +----- +name; + }; + + $printItem(new Item()); + } +} + +?> diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/recursive_form_view.php.inc b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/recursive_form_view.php.inc new file mode 100644 index 00000000000..a23f4240260 --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/recursive_form_view.php.inc @@ -0,0 +1,41 @@ + +----- + diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_already_typed.php.inc b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_already_typed.php.inc new file mode 100644 index 00000000000..b283e0b1485 --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_already_typed.php.inc @@ -0,0 +1,17 @@ +name; + }; + + $printItem(new Item()); + } +} diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_conflicting_types.php.inc b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_conflicting_types.php.inc new file mode 100644 index 00000000000..8e45e1e9da5 --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Fixture/skip_conflicting_types.php.inc @@ -0,0 +1,19 @@ + + */ +final class FormView implements \ArrayAccess +{ + public function offsetExists($offset): bool + { + return true; + } + + public function offsetGet($offset): self + { + return $this; + } + + public function offsetSet($offset, $value): void + { + } + + public function offsetUnset($offset): void + { + } +} diff --git a/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/Item.php b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/Item.php new file mode 100644 index 00000000000..4124b40429c --- /dev/null +++ b/rules-tests/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector/Source/Item.php @@ -0,0 +1,10 @@ +withRules([AddClosureParamTypeFromVariableCallRector::class]); diff --git a/rules/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector.php b/rules/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector.php new file mode 100644 index 00000000000..27e882b5632 --- /dev/null +++ b/rules/TypeDeclaration/Rector/FunctionLike/AddClosureParamTypeFromVariableCallRector.php @@ -0,0 +1,221 @@ +name; +}; + +$printItem(new Item()); +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +$printItem = function (Item $item) { + echo $item->name; +}; + +$printItem(new Item()); +CODE_SAMPLE + ), + ] + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [ClassMethod::class, Function_::class, Closure::class]; + } + + /** + * @param ClassMethod|Function_|Closure $node + */ + public function refactor(Node $node): ?Node + { + if ($node->stmts === null) { + return null; + } + + $closuresByVariableName = $this->collectAssignedClosures($node->stmts); + if ($closuresByVariableName === []) { + return null; + } + + $hasChanged = false; + + foreach ($closuresByVariableName as $variableName => $closure) { + $argumentTypesByPosition = $this->collectCallArgumentTypes($node->stmts, $variableName); + + foreach ($closure->getParams() as $position => $param) { + if (! isset($argumentTypesByPosition[$position])) { + continue; + } + + if ($this->refactorClosureParam($param, $argumentTypesByPosition[$position])) { + $hasChanged = true; + } + } + } + + if ($hasChanged) { + return $node; + } + + return null; + } + + /** + * @param Node\Stmt[] $stmts + * @return array + */ + private function collectAssignedClosures(array $stmts): array + { + $closuresByVariableName = []; + + $this->traverseNodesWithCallable($stmts, function (Node $node) use (&$closuresByVariableName): int|null { + // do not descend into nested scopes + if ($node instanceof Class_ || $node instanceof FunctionLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if (! $node instanceof Assign) { + return null; + } + + if (! $node->var instanceof Variable) { + return null; + } + + if (! $node->expr instanceof Closure) { + return null; + } + + $variableName = $this->getName($node->var); + if ($variableName === null) { + return null; + } + + $closuresByVariableName[$variableName] = $node->expr; + return null; + }); + + return $closuresByVariableName; + } + + /** + * @param Node\Stmt[] $stmts + * @return array + */ + private function collectCallArgumentTypes(array $stmts, string $variableName): array + { + $objectTypesByPosition = []; + $blockedPositions = []; + + $this->traverseNodesWithCallable($stmts, function (Node $node) use ( + $variableName, + &$objectTypesByPosition, + &$blockedPositions + ): int|null { + // do not descend into nested scopes, e.g. recursive self-calls + if ($node instanceof Class_ || $node instanceof FunctionLike) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if (! $node instanceof FuncCall || $node->isFirstClassCallable()) { + return null; + } + + if (! $node->name instanceof Variable || ! $this->isName($node->name, $variableName)) { + return null; + } + + foreach ($node->getArgs() as $position => $arg) { + if ($arg->unpack) { + $blockedPositions[$position] = true; + continue; + } + + $argType = $this->getType($arg->value); + if (! $argType instanceof ObjectType) { + $blockedPositions[$position] = true; + continue; + } + + // conflicting object types across calls → skip this position + if (isset($objectTypesByPosition[$position]) + && $objectTypesByPosition[$position]->getClassName() !== $argType->getClassName()) { + $blockedPositions[$position] = true; + continue; + } + + $objectTypesByPosition[$position] = $argType; + } + + return null; + }); + + foreach (array_keys($blockedPositions) as $position) { + unset($objectTypesByPosition[$position]); + } + + return $objectTypesByPosition; + } + + private function refactorClosureParam(Param $param, ObjectType $objectType): bool + { + if ($param->type instanceof Node) { + return false; + } + + if ($param->variadic) { + return false; + } + + $paramTypeNode = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode($objectType, TypeKind::PARAM); + if (! $paramTypeNode instanceof Node) { + return false; + } + + $param->type = $paramTypeNode; + return true; + } +} From ef602db4590e75a92fc16203301c9da2d12c832b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sat, 4 Jul 2026 16:30:06 +0200 Subject: [PATCH 2/3] Register AddClosureParamTypeFromVariableCallRector in TypeDeclarationLevel --- src/Config/Level/TypeDeclarationLevel.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Config/Level/TypeDeclarationLevel.php b/src/Config/Level/TypeDeclarationLevel.php index 9698fa73c33..a7ff86aaa9a 100644 --- a/src/Config/Level/TypeDeclarationLevel.php +++ b/src/Config/Level/TypeDeclarationLevel.php @@ -70,6 +70,7 @@ use Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeForArrayMapRector; use Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeForArrayReduceRector; use Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeFromIterableMethodCallRector; +use Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeFromVariableCallRector; use Rector\TypeDeclaration\Rector\FunctionLike\AddParamTypeSplFixedArrayRector; use Rector\TypeDeclaration\Rector\FunctionLike\AddReturnTypeDeclarationFromYieldsRector; use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector; @@ -186,5 +187,6 @@ final class TypeDeclarationLevel NarrowArrayAnyAllNullableParamTypeRector::class, AddArrayAnyAllClosureParamTypeRector::class, ClosureReturnTypeFromAssertInstanceOfRector::class, + AddClosureParamTypeFromVariableCallRector::class, ]; } From e2eaee1b5f32dd8255542a6672b2ba87018b53f0 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sat, 4 Jul 2026 16:48:36 +0200 Subject: [PATCH 3/3] Order AddClosureParamTypeFromVariableCallRector above StrictArrayParamDimFetchRector Infer the closure param object type before the array-dim-fetch rule runs, so a typed param is already present and the array fallback is skipped. --- src/Config/Level/TypeDeclarationLevel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/Level/TypeDeclarationLevel.php b/src/Config/Level/TypeDeclarationLevel.php index a7ff86aaa9a..a4772b1557e 100644 --- a/src/Config/Level/TypeDeclarationLevel.php +++ b/src/Config/Level/TypeDeclarationLevel.php @@ -174,6 +174,7 @@ final class TypeDeclarationLevel ScalarTypedPropertyFromJMSSerializerAttributeTypeRector::class, // array parameter from dim fetch assign inside + AddClosureParamTypeFromVariableCallRector::class, StrictArrayParamDimFetchRector::class, AddParamFromDimFetchKeyUseRector::class, AddParamStringTypeFromSprintfUseRector::class, @@ -187,6 +188,5 @@ final class TypeDeclarationLevel NarrowArrayAnyAllNullableParamTypeRector::class, AddArrayAnyAllClosureParamTypeRector::class, ClosureReturnTypeFromAssertInstanceOfRector::class, - AddClosureParamTypeFromVariableCallRector::class, ]; }