From 83371c7f98cc50b72573a18e4af8c89539bf75b0 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 15 Mar 2025 01:44:31 +0800 Subject: [PATCH 1/4] Add ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension --- docs/type-inference.md | 50 +++++ extension.neon | 8 + ...rivateMethodInvokerReturnTypeExtension.php | 96 +++++++++ ...micStaticMethodReturnTypeExtensionTest.php | 42 ++++ tests/Type/data/reflection-helper.php | 191 ++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 docs/type-inference.md create mode 100644 src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php create mode 100644 tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php create mode 100644 tests/Type/data/reflection-helper.php diff --git a/docs/type-inference.md b/docs/type-inference.md new file mode 100644 index 0000000..e8c61cd --- /dev/null +++ b/docs/type-inference.md @@ -0,0 +1,50 @@ +# Type Inference + +All type inference capabilities of this extension are summarised below: + +## Dynamic Static Method Return Type Extensions + +### ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension + +This extension provides precise return type to `ReflectionHelper`'s static `getPrivateMethodInvoker()` method. +Since PHPStan's dynamic return type extensions work on classes, not traits, this extension is on by default +in test cases extending `CodeIgniter\Test\CIUnitTestCase`. To make this work, you should be calling the method +**statically**: + +For example, we're accessing the private method `Foo::privateMethod()` which accepts a string parameter and returns bool. + +**Before** +```php +public function testSomePrivateMethod(): void +{ + $method = self::getPrivateMethodInvoker(new Foo(), 'privateMethod'); + \PHPStan\dumpType($method); // Closure(mixed ...): mixed +} + +``` + +**After** +```php +public function testSomePrivateMethod(): void +{ + $method = self::getPrivateMethodInvoker(new Foo(), 'privateMethod'); + \PHPStan\dumpType($method); // Closure(string): bool +} + +``` + +> [!NOTE] +> +> If you are using `ReflectionHelper` outside of testing, you can still enjoy the precise return types by adding a +> service for the class using this trait. In your `phpstan.neon` (or `phpstan.neon.dist`), add the following to +> the _**services**_ schema: +> +> ```yml +> - +> class: CodeIgniter\PHPStan\Type\ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension +> tags: +> - phpstan.broker.dynamicStaticMethodReturnTypeExtension +> arguments: +> class: +> +> ``` diff --git a/extension.neon b/extension.neon index 9af541b..7f978e7 100644 --- a/extension.neon +++ b/extension.neon @@ -77,6 +77,14 @@ services: tags: - phpstan.broker.dynamicMethodReturnTypeExtension + # DynamicStaticMethodReturnTypeExtension + - + class: CodeIgniter\PHPStan\Type\ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + arguments: + class: CodeIgniter\Test\CIUnitTestCase + # conditional rules - class: CodeIgniter\PHPStan\Rules\Functions\FactoriesFunctionArgumentTypeRule diff --git a/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php b/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php new file mode 100644 index 0000000..982af7b --- /dev/null +++ b/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\PHPStan\Type; + +use PhpParser\Node\Expr\StaticCall; +use PHPStan\Analyser\Scope; +use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ClosureType; +use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\NeverType; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; + +final class ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension +{ + /** + * @param class-string $class + */ + public function __construct( + private readonly string $class, + ) {} + + public function getClass(): string + { + return $this->class; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getPrivateMethodInvoker'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + + if (count($args) !== 2) { + return null; + } + + $objectType = $scope->getType($args[0]->value)->getObjectTypeOrClassStringObjectType(); + $methodType = $scope->getType($args[1]->value); + + if ($objectType->getObjectClassReflections() === [] && ! $objectType->isObject()->yes()) { + return new NeverType(true); + } + + $closures = []; + + foreach ($objectType->getObjectClassReflections() as $classReflection) { + foreach ($methodType->getConstantStrings() as $methodStringType) { + $methodName = $methodStringType->getValue(); + + if (! $classReflection->hasMethod($methodName)) { + $closures[] = new NeverType(true); + + continue; + } + + $methodReflection = $classReflection->getMethod($methodName, $scope); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $args, + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + + $closures[] = new ClosureType( + $parametersAcceptor->getParameters(), + $parametersAcceptor->getReturnType(), + $parametersAcceptor->isVariadic(), + $parametersAcceptor->getTemplateTypeMap(), + $parametersAcceptor->getResolvedTemplateTypeMap(), + ); + } + } + + if ($closures === []) { + return null; + } + + return TypeCombinator::union(...$closures); + } +} diff --git a/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php b/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php new file mode 100644 index 0000000..5cc35db --- /dev/null +++ b/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\PHPStan\Tests\Type; + +use CodeIgniter\PHPStan\Tests\AdditionalConfigFilesTrait; +use PHPStan\Testing\TypeInferenceTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Integration')] +final class DynamicStaticMethodReturnTypeExtensionTest extends TypeInferenceTestCase +{ + use AdditionalConfigFilesTrait; + + #[DataProvider('provideFileAssertsCases')] + public function testFileAsserts(string $assertType, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return iterable> + */ + public static function provideFileAssertsCases(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/reflection-helper.php'); + } +} diff --git a/tests/Type/data/reflection-helper.php b/tests/Type/data/reflection-helper.php new file mode 100644 index 0000000..354c354 --- /dev/null +++ b/tests/Type/data/reflection-helper.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\PHPStan\Tests\Fixtures\Type; + +use CodeIgniter\Commands\Utilities\ConfigCheck; +use CodeIgniter\Commands\Utilities\Environment; +use CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor; +use CodeIgniter\PHPStan\Type\FactoriesFunctionReturnTypeExtension; +use CodeIgniter\PHPStan\Type\ServicesFunctionReturnTypeExtension; +use CodeIgniter\Test\CIUnitTestCase; + +use function PHPStan\Testing\assertType; + +/** + * @internal + */ +final class ReflectionHelperGetPrivateMethodInvokerTest extends CIUnitTestCase +{ + public function testOnFirstClassCallable(): void + { + assertType( + 'Closure(object|string, string): (Closure(mixed ...$args=): mixed)', + self::getPrivateMethodInvoker(...), + ); + } + + public function testObjectAsObjectType(): void + { + assertType('Closure(): void', self::getPrivateMethodInvoker($this, 'testOnFirstClassCallable')); + + $object = new ModelReturnTypeTransformVisitor(); + assertType('Closure(PhpParser\Node): null', self::getPrivateMethodInvoker($object, 'enterNode')); + assertType( + 'Closure(array): (array|null)', + self::getPrivateMethodInvoker($object, 'afterTraverse'), + ); + + $object = new Environment(service('logger'), service('commands')); + assertType( + 'Closure(array): (int|void)', + self::getPrivateMethodInvoker($object, 'run'), + ); + assertType('Closure(string): bool', self::getPrivateMethodInvoker($object, 'writeNewEnvironmentToEnvFile')); + + $object = new ConfigCheck(service('logger'), service('commands')); + assertType( + 'Closure(array): (int|void)', + self::getPrivateMethodInvoker($object, 'run'), + ); + assertType('Closure(object): string', self::getPrivateMethodInvoker($object, 'getVarDump')); + assertType('Closure(object): string', self::getPrivateMethodInvoker($object, 'getKIntD')); + } + + public function testClassStringAsObjectType(): void + { + assertType('Closure(): void', self::getPrivateMethodInvoker(self::class, 'testOnFirstClassCallable')); + + $object = ModelReturnTypeTransformVisitor::class; + assertType('Closure(PhpParser\Node): null', self::getPrivateMethodInvoker($object, 'enterNode')); + assertType( + 'Closure(array): (array|null)', + self::getPrivateMethodInvoker($object, 'afterTraverse'), + ); + + $object = FactoriesFunctionReturnTypeExtension::class; + assertType( + 'Closure(CodeIgniter\PHPStan\Type\FactoriesReturnTypeHelper): void', + self::getPrivateMethodInvoker($object, '__construct'), + ); + assertType( + 'Closure(PHPStan\Reflection\FunctionReflection): bool', + self::getPrivateMethodInvoker($object, 'isFunctionSupported'), + ); + assertType( + 'Closure(PHPStan\Reflection\FunctionReflection, PhpParser\Node\Expr\FuncCall, PHPStan\Analyser\Scope): (PHPStan\Type\Type|null)', + self::getPrivateMethodInvoker($object, 'getTypeFromFunctionCall'), + ); + } + + public function testOnNamedArgumentCall(): void + { + $object = new ModelReturnTypeTransformVisitor(); + assertType( + 'Closure(PhpParser\Node): null', + self::getPrivateMethodInvoker(method: 'enterNode', obj: $object), + ); + assertType( + 'Closure(array): (array|null)', + self::getPrivateMethodInvoker(obj: $object, method: 'afterTraverse'), + ); + } + + public function testReturnIsNever(): void + { + assertType('*NEVER*', self::getPrivateMethodInvoker('NotClass', 'foo')); + assertType('*NEVER*', self::getPrivateMethodInvoker($this, 'inexistentMethod')); + } + + public function testOnString(string $object): void + { + assertType( + 'Closure(mixed ...): mixed', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param class-string $object + */ + public function testOnClassString(string $object): void + { + assertType( + 'Closure(mixed ...): mixed', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param class-string $class + */ + public function testOnGenericClassString(string $class): void + { + assertType( + 'Closure(Psr\Log\LoggerInterface, CodeIgniter\CLI\Commands): void', + self::getPrivateMethodInvoker($class, '__construct'), + ); + } + + public function testOnObject(object $object): void + { + assertType( + 'Closure(mixed ...): mixed', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param self $object + */ + public function testOnObjectWithClassType(object $object): void + { + assertType( + 'Closure(non-empty-string): void', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param class-string|self $object + */ + public function testOnUnionOfObjects(object|string $object): void + { + assertType( + '(Closure(CodeIgniter\PHPStan\Type\ServicesReturnTypeHelper): void)|(Closure(non-empty-string): void)', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param 'NotClass'|class-string $object + */ + public function testOnUnionOfStringObjectsWithOneNonClass(string $object): void + { + assertType( + '*NEVER*', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param '__construct'|'testReturnIsNever' $method + */ + public function testOnUnionOfMethods(string $method): void + { + assertType( + '(Closure(): void)|(Closure(non-empty-string): void)', + self::getPrivateMethodInvoker($this, $method), + ); + } +} From 692e2842a454881bc7bf1fe8aa56f11de6a0b8b1 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 15 Mar 2025 02:28:54 +0800 Subject: [PATCH 2/4] Add test on variadic arguments --- tests/Type/data/reflection-helper.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Type/data/reflection-helper.php b/tests/Type/data/reflection-helper.php index 354c354..1adbe07 100644 --- a/tests/Type/data/reflection-helper.php +++ b/tests/Type/data/reflection-helper.php @@ -188,4 +188,16 @@ public function testOnUnionOfMethods(string $method): void self::getPrivateMethodInvoker($this, $method), ); } + + public function testOnVariadicArguments(): void + { + $anon = new class () { + public function testing(string $a, int $b, bool $c, string ...$d): void {} + }; + + assertType( + 'Closure(string, int, bool, string ...): void', + self::getPrivateMethodInvoker($anon, 'testing'), + ); + } } From 78bef1a192a01d4f9f8aeed23faa7e21fac624e0 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 15 Mar 2025 02:29:13 +0800 Subject: [PATCH 3/4] Apply review --- docs/type-inference.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/type-inference.md b/docs/type-inference.md index e8c61cd..2ee55e7 100644 --- a/docs/type-inference.md +++ b/docs/type-inference.md @@ -11,7 +11,16 @@ Since PHPStan's dynamic return type extensions work on classes, not traits, this in test cases extending `CodeIgniter\Test\CIUnitTestCase`. To make this work, you should be calling the method **statically**: -For example, we're accessing the private method `Foo::privateMethod()` which accepts a string parameter and returns bool. +For example, we're accessing the private method: +```php +class Foo +{ + private static function privateMethod(string $value): bool + { + return true; + } +} +``` **Before** ```php From 15e47eea9a5e5cd0036dbb49c3c3b3d11754764d Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 16 Mar 2025 01:24:56 +0800 Subject: [PATCH 4/4] Reflected `__construct` should have object as return type --- ...rivateMethodInvokerReturnTypeExtension.php | 68 ++++++++++++------- tests/Type/data/reflection-helper.php | 18 +++-- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php b/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php index 982af7b..58d6cc6 100644 --- a/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php +++ b/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php @@ -19,9 +19,12 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\IntersectionType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; final class ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { @@ -53,44 +56,57 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $objectType = $scope->getType($args[0]->value)->getObjectTypeOrClassStringObjectType(); $methodType = $scope->getType($args[1]->value); - if ($objectType->getObjectClassReflections() === [] && ! $objectType->isObject()->yes()) { + if (! $objectType->isObject()->yes()) { return new NeverType(true); } - $closures = []; + return TypeTraverser::map($objectType, static function (Type $type, callable $traverse) use ($methodType, $scope, $args, $methodReflection): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + $closures = []; + + foreach ($type->getObjectClassReflections() as $classReflection) { + foreach ($methodType->getConstantStrings() as $methodStringType) { + $methodName = $methodStringType->getValue(); + + if (! $classReflection->hasMethod($methodName)) { + $closures[] = new NeverType(true); + + continue; + } + + $invokedMethodReflection = $classReflection->getMethod($methodName, $scope); - foreach ($objectType->getObjectClassReflections() as $classReflection) { - foreach ($methodType->getConstantStrings() as $methodStringType) { - $methodName = $methodStringType->getValue(); + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + [], + $invokedMethodReflection->getVariants(), + $invokedMethodReflection->getNamedArgumentsVariants(), + ); - if (! $classReflection->hasMethod($methodName)) { - $closures[] = new NeverType(true); + $returnType = strtolower($methodName) === '__construct' ? $type : $parametersAcceptor->getReturnType(); - continue; + $closures[] = new ClosureType( + $parametersAcceptor->getParameters(), + $returnType, + $parametersAcceptor->isVariadic(), + $parametersAcceptor->getTemplateTypeMap(), + $parametersAcceptor->getResolvedTemplateTypeMap(), + ); } + } - $methodReflection = $classReflection->getMethod($methodName, $scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + if ($closures === []) { + return ParametersAcceptorSelector::selectFromArgs( $scope, $args, $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), - ); - - $closures[] = new ClosureType( - $parametersAcceptor->getParameters(), - $parametersAcceptor->getReturnType(), - $parametersAcceptor->isVariadic(), - $parametersAcceptor->getTemplateTypeMap(), - $parametersAcceptor->getResolvedTemplateTypeMap(), - ); + )->getReturnType(); } - } - - if ($closures === []) { - return null; - } - return TypeCombinator::union(...$closures); + return TypeCombinator::union(...$closures); + }); } } diff --git a/tests/Type/data/reflection-helper.php b/tests/Type/data/reflection-helper.php index 1adbe07..98fcede 100644 --- a/tests/Type/data/reflection-helper.php +++ b/tests/Type/data/reflection-helper.php @@ -59,7 +59,7 @@ public function testObjectAsObjectType(): void self::getPrivateMethodInvoker($object, 'run'), ); assertType('Closure(object): string', self::getPrivateMethodInvoker($object, 'getVarDump')); - assertType('Closure(object): string', self::getPrivateMethodInvoker($object, 'getKIntD')); + assertType('Closure(object): string', self::getPrivateMethodInvoker($object, 'getKintD')); } public function testClassStringAsObjectType(): void @@ -75,7 +75,7 @@ public function testClassStringAsObjectType(): void $object = FactoriesFunctionReturnTypeExtension::class; assertType( - 'Closure(CodeIgniter\PHPStan\Type\FactoriesReturnTypeHelper): void', + 'Closure(CodeIgniter\PHPStan\Type\FactoriesReturnTypeHelper): CodeIgniter\PHPStan\Type\FactoriesFunctionReturnTypeExtension', self::getPrivateMethodInvoker($object, '__construct'), ); assertType( @@ -132,7 +132,7 @@ public function testOnClassString(string $object): void public function testOnGenericClassString(string $class): void { assertType( - 'Closure(Psr\Log\LoggerInterface, CodeIgniter\CLI\Commands): void', + 'Closure(Psr\Log\LoggerInterface, CodeIgniter\CLI\Commands): CodeIgniter\Commands\Utilities\ConfigCheck', self::getPrivateMethodInvoker($class, '__construct'), ); } @@ -146,12 +146,12 @@ public function testOnObject(object $object): void } /** - * @param self $object + * @param $this $object */ public function testOnObjectWithClassType(object $object): void { assertType( - 'Closure(non-empty-string): void', + 'Closure(non-empty-string): $this', self::getPrivateMethodInvoker($object, '__construct'), ); } @@ -162,7 +162,11 @@ public function testOnObjectWithClassType(object $object): void public function testOnUnionOfObjects(object|string $object): void { assertType( - '(Closure(CodeIgniter\PHPStan\Type\ServicesReturnTypeHelper): void)|(Closure(non-empty-string): void)', + sprintf( + '%s|%s', + '(Closure(CodeIgniter\PHPStan\Type\ServicesReturnTypeHelper): CodeIgniter\PHPStan\Type\ServicesFunctionReturnTypeExtension)', + '(Closure(non-empty-string): CodeIgniter\PHPStan\Tests\Fixtures\Type\ReflectionHelperGetPrivateMethodInvokerTest)', + ), self::getPrivateMethodInvoker($object, '__construct'), ); } @@ -184,7 +188,7 @@ public function testOnUnionOfStringObjectsWithOneNonClass(string $object): void public function testOnUnionOfMethods(string $method): void { assertType( - '(Closure(): void)|(Closure(non-empty-string): void)', + '(Closure(): void)|(Closure(non-empty-string): $this)', self::getPrivateMethodInvoker($this, $method), ); }