diff --git a/composer.json b/composer.json index 7f142fb..541952e 100644 --- a/composer.json +++ b/composer.json @@ -57,5 +57,12 @@ "allow-plugins": { "pestphp/pest-plugin": true } + }, + "extra": { + "phpstan": { + "includes": [ + "phpstan-extension.neon" + ] + } } } diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 473e513..bc97e93 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -29,6 +29,18 @@ import { Steps } from '@astrojs/starlight/components'; +## PHPStan extension + +Upon installation, if you're using `phpstan/extension-installer`, an extension will be enabled automatically. +Alternatively, you can enable it manually in your `phpstan.neon`: + +```neon +includes: + - vendor/good-php/reflection/phpstan-extension.neon +``` + +It improves some type inference, so we recommend keeping it on, but it's not required :) + ## Customizing cache It's crucial that you use some kind of cache. Out of the box, we support file-based cache and in-memory cache. diff --git a/phpstan-extension.neon b/phpstan-extension.neon new file mode 100644 index 0000000..73f6cc2 --- /dev/null +++ b/phpstan-extension.neon @@ -0,0 +1,5 @@ +services: + - + class: GoodPhp\Reflection\PHPStan\ReflectorReturnType + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/src/PHPStan/ReflectorReturnType.php b/src/PHPStan/ReflectorReturnType.php new file mode 100644 index 0000000..9ada9a1 --- /dev/null +++ b/src/PHPStan/ReflectorReturnType.php @@ -0,0 +1,132 @@ +getName() === 'forType'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): ?Type { + $reflectableType = $this->typeFromMethodCall($methodCall, $scope); + + if (!$reflectableType) { + return null; + } + + $reflectionTypeClassName = $this->reflectionTypeClassName($reflectableType); + + if (!$reflectionTypeClassName) { + return null; + } + + return new GenericObjectType($reflectionTypeClassName, [$reflectableType]); + } + + private function reflectionTypeClassName(Type $type): ?string + { + if (!$type->getObjectClassNames()) { + return SpecialTypeReflection::class; + } + + /** @var TypeWithClassName $type */ + /** @phpstan-ignore phpstanApi.varTagAssumption */ + $reflection = $type->getClassReflection(); + + if (!$reflection) { + return null; + } + + return match (true) { + $reflection->isClass() => ClassReflection::class, + $reflection->isInterface() => InterfaceReflection::class, + $reflection->isTrait() => TraitReflection::class, + $reflection->isEnum() => EnumReflection::class, + default => throw new RuntimeException('Unsupported return type') + }; + } + + private function typeFromMethodCall(MethodCall $methodCall, Scope $scope): ?Type + { + $nameArg = $methodCall->getArg('name', 0); + + if (!$nameArg) { + return null; + } + + $type = $scope->getType($nameArg->value); + + // Anonymous class + if ($type->getObjectClassNames()) { + return $type; + } + + // Regular ::class references + if ($type->isClassString()->yes()) { + $type = $type->getObjectTypeOrClassStringObjectType(); + + if (!$type->getObjectClassNames()) { + return null; + } + + return $type; + } + + // A couple of special cases for special types + if ($type->isString()->yes() && $type->isConstantValue()->yes()) { + $scalarValues = $type->getConstantScalarValues(); + + if (count($scalarValues) !== 1) { + return null; + } + + if (!in_array($scalarValues[0], self::SPECIAL_TYPES, true)) { + return null; + } + + /* @phpstan-ignore phpstanApi.method */ + return ParserNodeTypeToPHPStanType::resolve(new Identifier($scalarValues[0]), null); + } + + return null; + } +} diff --git a/tests/Types/reflector-return-type.php b/tests/Types/reflector-return-type.php new file mode 100644 index 0000000..73948a2 --- /dev/null +++ b/tests/Types/reflector-return-type.php @@ -0,0 +1,48 @@ +', $reflector->forType(Exception::class)); +assertType(InterfaceReflection::class . '', $reflector->forType(IteratorAggregate::class)); +assertType(TraitReflection::class . '', $reflector->forType(ObjectTypeTrait::class)); +assertType(EnumReflection::class . '', $reflector->forType(Visibility::class)); + +// Through ::class +/** @var IteratorAggregate $iterator */ +assertType(InterfaceReflection::class . '', $reflector->forType($iterator::class)); + +// Anonymous class. It's class name is random, so we can't use assertType() +assertSuperType(ClassReflection::class, $reflector->forType(new class () {})); + +// Special types +assertType(SpecialTypeReflection::class . '', $reflector->forType('object')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('string')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('int')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('float')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('bool')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('iterable')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('array')); +assertType(SpecialTypeReflection::class . '', $reflector->forType('callable')); + +// Can't infer +assertType(TypeReflection::class . '', $reflector->forType('unknown')); +assertType(TypeReflection::class . '', $reflector->forType(123)); +assertType(TypeReflection::class . '', $reflector->forType()); diff --git a/tests/Unit/PHPStan/PHPStanTypesTest.php b/tests/Unit/PHPStan/PHPStanTypesTest.php new file mode 100644 index 0000000..4f69653 --- /dev/null +++ b/tests/Unit/PHPStan/PHPStanTypesTest.php @@ -0,0 +1,28 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function fileAssertsProvider(): iterable + { + yield from self::gatherAssertTypesFromDirectory(__DIR__ . '/../../Types'); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../../phpstan-extension.neon']; + } +}