diff --git a/extension.neon b/extension.neon index dc73a43..b01f8a7 100644 --- a/extension.neon +++ b/extension.neon @@ -41,6 +41,20 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: Pest\PHPStan\Type\Pest\TestFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + + - + class: Pest\PHPStan\Type\Pest\PestTestFunction + + - + class: Pest\PHPStan\Type\Pest\TestFunctionProxyMethodExtension + tags: + - phpstan.broker.expressionTypeResolverExtension + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Type\Pest\ExpectationMethodReturnTypeExtension tags: diff --git a/src/Type/Pest/PestHookPropertyReader.php b/src/Type/Pest/PestHookPropertyReader.php index 5b72139..7c3005f 100644 --- a/src/Type/Pest/PestHookPropertyReader.php +++ b/src/Type/Pest/PestHookPropertyReader.php @@ -288,17 +288,7 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $ continue; } - if (! $var->var instanceof Variable) { - continue; - } - - $thisVariable = $var->var; - - if (! is_string($thisVariable->name)) { - continue; - } - - if ($thisVariable->name !== 'this') { + if (! $this->isTestCaseReference($var->var)) { continue; } @@ -330,6 +320,18 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $ return $properties; } + private function isTestCaseReference(Expr $expr): bool + { + // @note: a bare test() proxies the running test case, so `test()->name = …` sets the same property as `$this->name = …`. + if ($expr instanceof FuncCall) { + return $expr->name instanceof Name + && $expr->name->toString() === 'test' + && $expr->getArgs() === []; + } + + return $expr instanceof Variable && $expr->name === 'this'; + } + /** * @param array $useMap * @return array diff --git a/src/Type/Pest/PestTestCaseType.php b/src/Type/Pest/PestTestCaseType.php index 1da0108..2971d87 100644 --- a/src/Type/Pest/PestTestCaseType.php +++ b/src/Type/Pest/PestTestCaseType.php @@ -19,6 +19,12 @@ public function __construct( public function resolve(string $filePath): Type { + return $this->resolveIfBound($filePath) ?? new ObjectType(TestCase::class); + } + + public function resolveIfBound(string $filePath): ?Type + { + // @note: returns null when no binding covers the file, so callers can tell an unbound file from one bound to PHPUnit's TestCase. [$classNames, $traitNames] = $this->partition( $this->pestConfigReader->resolveFileBindings($filePath), ); @@ -31,6 +37,10 @@ public function resolve(string $filePath): Type $traitNames = array_values(array_unique([...$directoryTraitNames, ...$traitNames])); } + if ($classNames === [] && $traitNames === []) { + return null; + } + if ($classNames === []) { $classNames[] = TestCase::class; } diff --git a/src/Type/Pest/PestTestFunction.php b/src/Type/Pest/PestTestFunction.php new file mode 100644 index 0000000..5a9c806 --- /dev/null +++ b/src/Type/Pest/PestTestFunction.php @@ -0,0 +1,57 @@ +isPestFunction($function, 'test'); + } + + public function supportsTodoHelper(Scope $scope): bool + { + $name = new FullyQualified('todo'); + + return $this->reflectionProvider->hasFunction($name, $scope) + && $this->isPestFunction($this->reflectionProvider->getFunction($name, $scope), 'todo'); + } + + public function isBareCall(Expr $expression, Scope $scope): bool + { + return $expression instanceof FuncCall + && $expression->name instanceof Name + && $expression->getArgs() === [] + && $this->reflectionProvider->hasFunction($expression->name, $scope) + && $this->supports($this->reflectionProvider->getFunction($expression->name, $scope)); + } + + private function isPestFunction(FunctionReflection $function, string $name): bool + { + if ($function->getName() !== $name) { + return false; + } + + $filename = $function->getFileName(); + $testSuiteFilename = $this->reflectionProvider->getClass(TestSuite::class)->getFileName(); + + return $filename !== null + && $testSuiteFilename !== null + && str_replace('\\', '/', $filename) === str_replace('\\', '/', dirname($testSuiteFilename).'/Functions.php'); + } +} diff --git a/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php b/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php index f8606d4..50c3c22 100644 --- a/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php +++ b/src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php @@ -26,6 +26,10 @@ final class TestCaseMemberVisibilityIgnoreExtension implements IgnoreErrorExtens private const array CONSTANT_IDENTIFIERS = ['classConstant.protected', 'classConstant.private']; + public function __construct( + private readonly PestTestFunction $testFunction, + ) {} + public function shouldIgnore(Error $error, Node $node, Scope $scope): bool { $identifier = $error->getIdentifier(); @@ -37,20 +41,19 @@ public function shouldIgnore(Error $error, Node $node, Scope $scope): bool default => null, }; - if (! $receiver instanceof Variable || $receiver->name !== 'this') { - return false; - } - - if (! $scope->isInAnonymousFunction()) { + if ($receiver instanceof Expr && $this->testFunction->isBareCall($receiver, $scope)) { + $thisType = $scope->getType($receiver); + } elseif ( + $receiver instanceof Variable + && $receiver->name === 'this' + && $scope->isInAnonymousFunction() + && $scope->hasVariableType('this')->yes() + ) { + $thisType = $scope->getVariableType('this'); + } else { return false; } - if (! $scope->hasVariableType('this')->yes()) { - return false; - } - - $thisType = $scope->getVariableType('this'); - if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { return false; } diff --git a/src/Type/Pest/TestFunctionProxyMethodExtension.php b/src/Type/Pest/TestFunctionProxyMethodExtension.php new file mode 100644 index 0000000..40f9136 --- /dev/null +++ b/src/Type/Pest/TestFunctionProxyMethodExtension.php @@ -0,0 +1,47 @@ +isTodoCall($expr, $scope) ? new ObjectType(TestCall::class) : null; + } + + public function shouldIgnore(Error $error, Node $node, Scope $scope): bool + { + return $error->getIdentifier() === 'method.notFound' + && $this->isTodoCall($node, $scope); + } + + private function isTodoCall(Node $node, Scope $scope): bool + { + return $node instanceof MethodCall + && $node->name instanceof Identifier + && $node->name->toLowerString() === 'todo' + && $this->testFunction->isBareCall($node->var, $scope) + && $this->testCaseType->resolveIfBound($scope->getFile()) instanceof Type + && ! $scope->getType($node->var)->hasMethod('todo')->yes() + && $this->testFunction->supportsTodoHelper($scope); + } +} diff --git a/src/Type/Pest/TestFunctionReturnTypeExtension.php b/src/Type/Pest/TestFunctionReturnTypeExtension.php new file mode 100644 index 0000000..3cbd806 --- /dev/null +++ b/src/Type/Pest/TestFunctionReturnTypeExtension.php @@ -0,0 +1,52 @@ +testFunction->supports($functionReflection); + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope + ): ?Type { + if ($functionCall->getArgs() !== []) { + return null; + } + + $boundType = $this->pestTestCaseType->resolveIfBound($scope->getFile()); + + if (! $boundType instanceof Type) { + return null; + } + + if ($scope->hasVariableType('this')->yes()) { + $thisType = $scope->getVariableType('this'); + + if (new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) { + return $thisType; + } + } + + return $boundType; + } +} diff --git a/tests/Analysis/PestTestFunctionTest.php b/tests/Analysis/PestTestFunctionTest.php new file mode 100644 index 0000000..b1b59b9 --- /dev/null +++ b/tests/Analysis/PestTestFunctionTest.php @@ -0,0 +1,27 @@ +getByType(ReflectionProvider::class); + $testFunction = new PestTestFunction($provider); + $pestFilename = $provider->getClass(TestSuite::class)->getFileName(); + + expect($pestFilename)->not->toBeNull(); + + $pest = $this->createStub(FunctionReflection::class); + $pest->method('getName')->willReturn('test'); + $pest->method('getFileName')->willReturn(dirname($pestFilename).'/Functions.php'); + + $custom = $this->createStub(FunctionReflection::class); + $custom->method('getName')->willReturn('test'); + $custom->method('getFileName')->willReturn(__FILE__); + + expect($testFunction->supports($pest))->toBeTrue() + ->and($testFunction->supports($custom))->toBeFalse(); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php new file mode 100644 index 0000000..9518d31 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php @@ -0,0 +1,79 @@ +createHelper()); +} + +function testBareTestCallInStaticCallback(): void +{ + it('uses the bound type in static callbacks without $this', function (): void { + $resolve = static fn () => test(); + + assertType(CustomTestCase::class, $resolve()); + }); +} + +function testBareTestCallInNestedArrowFunction(): void +{ + it('types a bare test() inside a nested arrow function', function (): void { + $resolve = fn () => test(); + + assertType(CustomTestCase::class, $resolve()); + }); +} + +function testMethodCallThroughBareTestCall(): void +{ + it('resolves methods through a bare test()', function (): void { + assertType('string', test()->createHelper()); + }); +} + +function testHookPropertyReadThroughBareTestCall(): void +{ + beforeEach(function (): void { + test()->post = new Post; + }); + + it('resolves a hook property read through a bare test()', function (): void { + assertType(Post::class, test()->post); + }); +} + +function testTestCallWithDescriptionIsUnchanged(): void +{ + assertType(TestCall::class, test('has a description')); +} + +function testTodoThroughBareTestCall(): void +{ + it('preserves the todo helper on the proxy', function (): void { + assertType(TestCall::class, test()->todo('implement this test')); + }); +} diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php new file mode 100644 index 0000000..bb8a8ea --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php @@ -0,0 +1,13 @@ +todo()); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php new file mode 100644 index 0000000..7b157eb --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php @@ -0,0 +1,24 @@ +createMock(stdClass::class); + + return test()->createHelper(); +} + +it('uses protected methods from static callbacks', function (): void { + $resolve = static fn (): string => test()->createHelper(); + + $resolve(); +}); + +it('can use the todo helper through the proxy', function (): void { + test()->todo('implement this test')->group('pending'); +}); diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php new file mode 100644 index 0000000..0fdd2b9 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php @@ -0,0 +1,10 @@ +createHelpre(); +} diff --git a/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php b/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php index 167c003..b59e54e 100644 --- a/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php +++ b/tests/Fixtures/CustomTestCaseInference/TraitVisibility/trait-visibility-calls.php @@ -26,3 +26,9 @@ $this->privateTraitMethod(); $this::privateStaticTraitMethod(); }); + +function helperUsingBoundTraitMethods(): void +{ + test()->protectedTraitMethod(); + test()->privateTraitMethod(); +} diff --git a/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php b/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php index 01ced9f..1439847 100644 --- a/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php +++ b/tests/Rules/ProtectedMethodCallCustomTestCaseTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\Methods\CallMethodsRule; use Tests\RuleTestCase; +use Tests\Type\Fixtures\CustomTestCase; beforeAll(function (): void { RuleTestCase::$additionalConfigFiles = [ @@ -19,3 +20,17 @@ __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/protected-method-calls-custom-testcase.php', ], []); }); + +test('bare test calls allow protected methods and proxy helpers', function (): void { + $this->analyse([ + __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-method-calls.php', + ], []); +}); + +test('bare test calls still report undefined methods in helpers', function (): void { + $this->analyse([ + __DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-undefined-method.php', + ], [ + ['Call to an undefined method '.CustomTestCase::class.'::createHelpre().', 9], + ]); +}); diff --git a/tests/Type/CustomTestCaseTest.php b/tests/Type/CustomTestCaseTest.php index a13648b..013e2d5 100644 --- a/tests/Type/CustomTestCaseTest.php +++ b/tests/Type/CustomTestCaseTest.php @@ -11,6 +11,13 @@ yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php'); }); +test('a bare test() call types as the bound custom testcase', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-custom-testcase.php'); + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-function-custom-todo.php'); +}); + test('custom testcase closure types when a class and a trait are bound', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/ExpectTypeTest.php b/tests/Type/ExpectTypeTest.php index ccb4808..bd47d2f 100644 --- a/tests/Type/ExpectTypeTest.php +++ b/tests/Type/ExpectTypeTest.php @@ -16,6 +16,12 @@ yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-closures.php'); }); +test('bare test() call types', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-function.php'); +}); + test('with closure types', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/Fixtures/CustomTodoTestCase.php b/tests/Type/Fixtures/CustomTodoTestCase.php new file mode 100644 index 0000000..0fd747f --- /dev/null +++ b/tests/Type/Fixtures/CustomTodoTestCase.php @@ -0,0 +1,15 @@ +cycleFirst); }); } + +function testBeforeEachAssignmentThroughTestFunction(): void +{ + beforeEach(function (): void { + test()->viaTestFunction = new Post; + }); + + it('resolves a property a hook sets through test()', function (): void { + assertType(Post::class, $this->viaTestFunction); + }); +} + +function testBeforeEachAssignmentThroughTestFunctionWithLocalVariable(): void +{ + beforeEach(function (): void { + $author = new Author; + test()->viaTestFunctionLocal = $author; + }); + + it('resolves a local variable a hook sets through test()', function (): void { + assertType(Author::class, $this->viaTestFunctionLocal); + }); +}