diff --git a/docs/type-inference.md b/docs/type-inference.md new file mode 100644 index 0000000..2ee55e7 --- /dev/null +++ b/docs/type-inference.md @@ -0,0 +1,59 @@ +# 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: +```php +class Foo +{ + private static function privateMethod(string $value): bool + { + return true; + } +} +``` + +**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..58d6cc6 --- /dev/null +++ b/src/Type/ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension.php @@ -0,0 +1,112 @@ + + * + * 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\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 +{ + /** + * @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->isObject()->yes()) { + return new NeverType(true); + } + + 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); + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + [], + $invokedMethodReflection->getVariants(), + $invokedMethodReflection->getNamedArgumentsVariants(), + ); + + $returnType = strtolower($methodName) === '__construct' ? $type : $parametersAcceptor->getReturnType(); + + $closures[] = new ClosureType( + $parametersAcceptor->getParameters(), + $returnType, + $parametersAcceptor->isVariadic(), + $parametersAcceptor->getTemplateTypeMap(), + $parametersAcceptor->getResolvedTemplateTypeMap(), + ); + } + } + + if ($closures === []) { + return ParametersAcceptorSelector::selectFromArgs( + $scope, + $args, + $methodReflection->getVariants(), + )->getReturnType(); + } + + 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..98fcede --- /dev/null +++ b/tests/Type/data/reflection-helper.php @@ -0,0 +1,207 @@ + + * + * 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): CodeIgniter\PHPStan\Type\FactoriesFunctionReturnTypeExtension', + 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): CodeIgniter\Commands\Utilities\ConfigCheck', + self::getPrivateMethodInvoker($class, '__construct'), + ); + } + + public function testOnObject(object $object): void + { + assertType( + 'Closure(mixed ...): mixed', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param $this $object + */ + public function testOnObjectWithClassType(object $object): void + { + assertType( + 'Closure(non-empty-string): $this', + self::getPrivateMethodInvoker($object, '__construct'), + ); + } + + /** + * @param class-string|self $object + */ + public function testOnUnionOfObjects(object|string $object): void + { + assertType( + 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'), + ); + } + + /** + * @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): $this)', + 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'), + ); + } +}