diff --git a/extension.neon b/extension.neon index 7808525..aab2056 100644 --- a/extension.neon +++ b/extension.neon @@ -69,6 +69,11 @@ services: tags: - phpstan.ignoreErrorExtension + - + class: Pest\PHPStan\Type\Pest\ArchExpectationPropertyIgnoreExtension + tags: + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Type\Pest\TestCallMethodsClassReflectionExtension tags: diff --git a/src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php b/src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php new file mode 100644 index 0000000..f9e3302 --- /dev/null +++ b/src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php @@ -0,0 +1,36 @@ +getIdentifier() !== 'property.notFound') { + return false; + } + + if (! $node instanceof PropertyFetch) { + return false; + } + + if (! $node->name instanceof Identifier || ! in_array($node->name->name, ExpectationPropertiesExtension::KNOWN_EXPECTATION_PROPERTIES, true)) { + return false; + } + + return (new ObjectType(ArchExpectation::class)) + ->isSuperTypeOf($scope->getType($node->var)) + ->yes(); + } +} diff --git a/src/Type/Pest/ArchExpectationTypeResolver.php b/src/Type/Pest/ArchExpectationTypeResolver.php new file mode 100644 index 0000000..5916622 --- /dev/null +++ b/src/Type/Pest/ArchExpectationTypeResolver.php @@ -0,0 +1,31 @@ +getResolvedMixinTypes() as $mixinType) { + $mixinClassReflections = $mixinType->getObjectClassReflections(); + foreach ($mixinClassReflections as $mixinClassReflection) { + if ($mixinClassReflection->is(Expectation::class)) { + $tValue = $mixinType->getTemplateType(Expectation::class, 'TValue'); + + return new GenericObjectType(OppositeExpectation::class, [$tValue]); + } + } + } + + return new GenericObjectType(OppositeExpectation::class, [new ObjectType('string')]); + } +} diff --git a/src/Type/Pest/ExpectationPropertiesExtension.php b/src/Type/Pest/ExpectationPropertiesExtension.php index 322c7b2..a843201 100644 --- a/src/Type/Pest/ExpectationPropertiesExtension.php +++ b/src/Type/Pest/ExpectationPropertiesExtension.php @@ -4,6 +4,7 @@ namespace Pest\PHPStan\Type\Pest; +use Pest\Arch\Contracts\ArchExpectation; use Pest\Expectation; use Pest\Expectations\HigherOrderExpectation; use PHPStan\Reflection\ClassReflection; @@ -14,10 +15,14 @@ final class ExpectationPropertiesExtension implements PropertiesClassReflectionExtension { /** @var list */ - private const KNOWN_EXPECTATION_PROPERTIES = ['not', 'each', 'classes', 'traits', 'interfaces', 'enums', 'value']; + public const KNOWN_EXPECTATION_PROPERTIES = ['not', 'each', 'classes', 'traits', 'interfaces', 'enums', 'value']; public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { + if ($classReflection->is(ArchExpectation::class) && $propertyName === 'not' && ! $classReflection->hasNativeProperty($propertyName)) { + return true; + } + if ($classReflection->is(Expectation::class)) { return ! in_array($propertyName, self::KNOWN_EXPECTATION_PROPERTIES, true) && ! $classReflection->hasNativeProperty($propertyName); @@ -32,6 +37,13 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection { + if ($classReflection->is(ArchExpectation::class) && $propertyName === 'not') { + return new PestTestCaseProperty( + $classReflection, + ArchExpectationTypeResolver::getNotPropertyType($classReflection), + ); + } + return new PestTestCaseProperty($classReflection, new MixedType); } } diff --git a/src/Type/Pest/HigherOrderExpectationTypeExtension.php b/src/Type/Pest/HigherOrderExpectationTypeExtension.php index 2f60a95..d6dd825 100644 --- a/src/Type/Pest/HigherOrderExpectationTypeExtension.php +++ b/src/Type/Pest/HigherOrderExpectationTypeExtension.php @@ -4,6 +4,7 @@ namespace Pest\PHPStan\Type\Pest; +use Pest\Arch\Contracts\ArchExpectation; use Pest\Expectation; use Pest\Expectations\HigherOrderExpectation; use Pest\Expectations\OppositeExpectation; @@ -15,6 +16,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -49,6 +51,15 @@ private function resolvePropertyFetch(PropertyFetch $expr, Scope $scope): ?Type $propertyName = $expr->name->name; $varType = $scope->getType($expr->var); + if ($propertyName === 'not') { + $archType = new ObjectType(ArchExpectation::class); + if ($archType->isSuperTypeOf($varType)->yes()) { + $valueType = $this->extractTValueFromArchExpectationChain($expr, $scope); + + return new GenericObjectType(OppositeExpectation::class, [$valueType]); + } + } + $expectationType = new ObjectType(Expectation::class); if ($expectationType->isSuperTypeOf($varType)->yes()) { return $this->resolveExpectationPropertyFetch($varType, $propertyName, $scope); @@ -62,6 +73,20 @@ private function resolvePropertyFetch(PropertyFetch $expr, Scope $scope): ?Type return null; } + private function extractTValueFromArchExpectationChain(PropertyFetch $expr, Scope $scope): Type + { + if ($expr->var instanceof MethodCall) { + $originalType = $scope->getType($expr->var->var); + $tValue = $originalType->getTemplateType(Expectation::class, 'TValue'); + + if (! $tValue instanceof MixedType) { + return $tValue; + } + } + + return new ObjectType('string'); + } + private function resolveExpectationPropertyFetch(Type $varType, string $propertyName, Scope $scope): ?Type { if ($propertyName === 'not') { diff --git a/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php b/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php new file mode 100644 index 0000000..3e73911 --- /dev/null +++ b/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php @@ -0,0 +1,21 @@ +analyse([ + __DIR__.'/data/arch-expectation-not-property.php', + ], []); +}); diff --git a/tests/Rules/data/arch-expectation-not-property.php b/tests/Rules/data/arch-expectation-not-property.php new file mode 100644 index 0000000..802889e --- /dev/null +++ b/tests/Rules/data/arch-expectation-not-property.php @@ -0,0 +1,9 @@ +toUseStrictTypes()->not->toUse(['dd', 'dump']); + expect('App')->toUseStrictTypes()->not->toBeFinal(); + expect(['App\Models', 'App\Services'])->toUseStrictTypes()->not->toUse('Illuminate\Support\Facades\DB'); +}); diff --git a/tests/Type/data/arch-expectations.php b/tests/Type/data/arch-expectations.php index 88b73dc..d7aae9e 100644 --- a/tests/Type/data/arch-expectations.php +++ b/tests/Type/data/arch-expectations.php @@ -109,3 +109,38 @@ function testToBeInvokable(): void $result = expect('App\Actions')->toBeInvokable(); assertType(ArchExpectation::class, $result); } +function testNotOnArchExpectation(): void +{ + $result = expect('App')->toUseStrictTypes()->not; + assertType("Pest\Expectations\OppositeExpectation<'App'>", $result); +} + +function testNotArchExpectationChainToUse(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toUse(['dd', 'dump']); + assertType(ArchExpectation::class, $result); +} + +function testNotArchExpectationChainToBeFinal(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toBeFinal(); + assertType(ArchExpectation::class, $result); +} + +function testNotArchExpectationChainToImplement(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toImplement('SomeInterface'); + assertType(ArchExpectation::class, $result); +} + +function testNotOnExpectDirectly(): void +{ + $result = expect('App')->not->toUse(['dd', 'dump']); + assertType(ArchExpectation::class, $result); +} + +function testNotOnArchExpectationArrayTarget(): void +{ + $result = expect(['App\Models', 'App\Services'])->toUseStrictTypes()->not; + assertType('Pest\Expectations\OppositeExpectation', $result); +}