Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,12 @@
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"phpstan": {
"includes": [
"phpstan-extension.neon"
]
}
}
}
12 changes: 12 additions & 0 deletions docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ import { Steps } from '@astrojs/starlight/components';

</Steps>

## 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.
Expand Down
5 changes: 5 additions & 0 deletions phpstan-extension.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
-
class: GoodPhp\Reflection\PHPStan\ReflectorReturnType
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
132 changes: 132 additions & 0 deletions src/PHPStan/ReflectorReturnType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace GoodPhp\Reflection\PHPStan;

use GoodPhp\Reflection\Reflection\ClassReflection;
use GoodPhp\Reflection\Reflection\EnumReflection;
use GoodPhp\Reflection\Reflection\InterfaceReflection;
use GoodPhp\Reflection\Reflection\SpecialTypeReflection;
use GoodPhp\Reflection\Reflection\TraitReflection;
use GoodPhp\Reflection\Reflector;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\ParserNodeTypeToPHPStanType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeWithClassName;
use RuntimeException;

class ReflectorReturnType implements DynamicMethodReturnTypeExtension
{
private const SPECIAL_TYPES = [
'object',
'string',
'int',
'float',
'bool',
'iterable',
'array',
'callable',
];

public function getClass(): string
{
return Reflector::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->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;
}
}
48 changes: 48 additions & 0 deletions tests/Types/reflector-return-type.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Tests\Types;

use Exception;
use GoodPhp\Reflection\Reflection\ClassReflection;
use GoodPhp\Reflection\Reflection\EnumReflection;
use GoodPhp\Reflection\Reflection\InterfaceReflection;
use GoodPhp\Reflection\Reflection\SpecialTypeReflection;
use GoodPhp\Reflection\Reflection\TraitReflection;
use GoodPhp\Reflection\Reflection\TypeReflection;
use GoodPhp\Reflection\Reflector;
use IteratorAggregate;
use PHPStan\Type\Traits\ObjectTypeTrait;
use PHPUnit\Architecture\Enums\Visibility;

use function PHPStan\Testing\assertSuperType;
use function PHPStan\Testing\assertType;

/** @var Reflector $reflector */

// Constant class-string
assertType(ClassReflection::class . '<Exception>', $reflector->forType(Exception::class));
assertType(InterfaceReflection::class . '<IteratorAggregate>', $reflector->forType(IteratorAggregate::class));
assertType(TraitReflection::class . '<PHPStan\Type\Traits\ObjectTypeTrait>', $reflector->forType(ObjectTypeTrait::class));
assertType(EnumReflection::class . '<PHPUnit\Architecture\Enums\Visibility>', $reflector->forType(Visibility::class));

// Through ::class
/** @var IteratorAggregate $iterator */
assertType(InterfaceReflection::class . '<IteratorAggregate>', $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 . '<object>', $reflector->forType('object'));
assertType(SpecialTypeReflection::class . '<string>', $reflector->forType('string'));
assertType(SpecialTypeReflection::class . '<int>', $reflector->forType('int'));
assertType(SpecialTypeReflection::class . '<float>', $reflector->forType('float'));
assertType(SpecialTypeReflection::class . '<bool>', $reflector->forType('bool'));
assertType(SpecialTypeReflection::class . '<iterable>', $reflector->forType('iterable'));
assertType(SpecialTypeReflection::class . '<array>', $reflector->forType('array'));
assertType(SpecialTypeReflection::class . '<callable(): mixed>', $reflector->forType('callable'));

// Can't infer
assertType(TypeReflection::class . '<mixed>', $reflector->forType('unknown'));
assertType(TypeReflection::class . '<mixed>', $reflector->forType(123));
assertType(TypeReflection::class . '<mixed>', $reflector->forType());
28 changes: 28 additions & 0 deletions tests/Unit/PHPStan/PHPStanTypesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Tests\Unit\PHPStan;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class PHPStanTypesTest extends TypeInferenceTestCase
{
#[DataProvider('fileAssertsProvider')]
public function testFileAsserts(
string $assertType,
string $file,
mixed ...$args
): void {
$this->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'];
}
}