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
5 changes: 5 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ services:
tags:
- phpstan.ignoreErrorExtension

-
class: Pest\PHPStan\Type\Pest\ArchExpectationPropertyIgnoreExtension
tags:
- phpstan.ignoreErrorExtension

-
class: Pest\PHPStan\Type\Pest\TestCallMethodsClassReflectionExtension
tags:
Expand Down
36 changes: 36 additions & 0 deletions src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\Arch\Contracts\ArchExpectation;
use PhpParser\Node;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\IgnoreErrorExtension;
use PHPStan\Analyser\Scope;
use PHPStan\Type\ObjectType;

final class ArchExpectationPropertyIgnoreExtension implements IgnoreErrorExtension
{
public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
{
if ($error->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();
}
}
31 changes: 31 additions & 0 deletions src/Type/Pest/ArchExpectationTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\Expectation;
use Pest\Expectations\OppositeExpectation;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

final class ArchExpectationTypeResolver
{
public static function getNotPropertyType(ClassReflection $classReflection): Type
{
foreach ($classReflection->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')]);
}
}
14 changes: 13 additions & 1 deletion src/Type/Pest/ExpectationPropertiesExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,10 +15,14 @@
final class ExpectationPropertiesExtension implements PropertiesClassReflectionExtension
{
/** @var list<string> */
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);
Expand All @@ -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);
}
}
25 changes: 25 additions & 0 deletions src/Type/Pest/HigherOrderExpectationTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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') {
Expand Down
21 changes: 21 additions & 0 deletions tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Tests\Rules;

use PHPStan\Rules\Properties\AccessPropertiesRule;
use Tests\RuleTestCase;

beforeAll(function (): void {
RuleTestCase::$additionalConfigFiles = [
__DIR__.'/../extension.neon',
];
RuleTestCase::$rule = RuleTestCase::resolveRule(AccessPropertiesRule::class);
});

test('not property access on arch expectations does not report undefined property', function (): void {
$this->analyse([
__DIR__.'/data/arch-expectation-not-property.php',
], []);
});
9 changes: 9 additions & 0 deletions tests/Rules/data/arch-expectation-not-property.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

test('not property on arch expectation is allowed', function (): void {
expect('App')->toUseStrictTypes()->not->toUse(['dd', 'dump']);
expect('App')->toUseStrictTypes()->not->toBeFinal();
expect(['App\Models', 'App\Services'])->toUseStrictTypes()->not->toUse('Illuminate\Support\Facades\DB');
});
35 changes: 35 additions & 0 deletions tests/Type/data/arch-expectations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{\'App\\\\Models\', \'App\\\\Services\'}>', $result);
}