diff --git a/extension.neon b/extension.neon index aab2056..7ee5975 100644 --- a/extension.neon +++ b/extension.neon @@ -84,6 +84,11 @@ services: tags: - phpstan.ignoreErrorExtension + - + class: Pest\PHPStan\Type\Pest\BoundTraitMethodCallIgnoreExtension + tags: + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Rules\InvalidThrowsExceptionRule arguments: diff --git a/src/Type/Pest/BoundTraitMethodCallIgnoreExtension.php b/src/Type/Pest/BoundTraitMethodCallIgnoreExtension.php new file mode 100644 index 0000000..0927d90 --- /dev/null +++ b/src/Type/Pest/BoundTraitMethodCallIgnoreExtension.php @@ -0,0 +1,61 @@ +getIdentifier() !== 'method.notFound') { + return false; + } + + if (! $node instanceof MethodCall) { + return false; + } + + if (! $node->var instanceof Variable || $node->var->name !== 'this') { + return false; + } + + if (! $node->name instanceof Identifier) { + return false; + } + + $methodName = $node->name->toString(); + + return array_any($this->boundTraits($scope->getFile()), fn (string $trait): bool => $this->reflectionProvider->getClass($trait)->hasNativeMethod($methodName)); + } + + /** + * @return list + */ + private function boundTraits(string $file): array + { + $bindings = [ + ...$this->pestConfigReader->resolveFileBindings($file), + ...$this->pestConfigReader->resolveBindings($file), + ]; + + return array_values(array_filter( + $bindings, + fn (string $class): bool => $this->reflectionProvider->hasClass($class) && $this->reflectionProvider->getClass($class)->isTrait(), + )); + } +} diff --git a/tests/Type/BoundTraitMethodCallIgnoreExtensionTest.php b/tests/Type/BoundTraitMethodCallIgnoreExtensionTest.php new file mode 100644 index 0000000..794c1e3 --- /dev/null +++ b/tests/Type/BoundTraitMethodCallIgnoreExtensionTest.php @@ -0,0 +1,77 @@ +getByType(PestConfigReader::class), + TestCase::getContainer()->getByType(ReflectionProvider::class), + ); +} + +function methodNotFoundError(string $file): Error +{ + return new Error( + 'Call to an undefined method.', + $file, + identifier: 'method.notFound', + ); +} + +function traitOnlyFixtureFile(): string +{ + $file = realpath(__DIR__.'/../Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php'); + + if ($file === false) { + throw new RuntimeException('Fixture file not found.'); + } + + return $file; +} + +test('ignores a call to a method declared on a trait bound via file-level uses()', function (): void { + $file = traitOnlyFixtureFile(); + $node = new MethodCall(new Variable('this'), new Identifier('helperMethod')); + $scope = $this->createStub(Scope::class); + $scope->method('getFile')->willReturn($file); + + expect(boundTraitMethodCallIgnoreExtension()->shouldIgnore(methodNotFoundError($file), $node, $scope))->toBeTrue(); +}); + +test('does not ignore a method not declared on any bound trait', function (): void { + $file = traitOnlyFixtureFile(); + $node = new MethodCall(new Variable('this'), new Identifier('notARealMethod')); + $scope = $this->createStub(Scope::class); + $scope->method('getFile')->willReturn($file); + + expect(boundTraitMethodCallIgnoreExtension()->shouldIgnore(methodNotFoundError($file), $node, $scope))->toBeFalse(); +}); + +test('does not ignore errors other than method.notFound', function (): void { + $file = traitOnlyFixtureFile(); + $node = new MethodCall(new Variable('this'), new Identifier('helperMethod')); + $scope = $this->createStub(Scope::class); + $error = new Error('Call to a protected method.', $file, identifier: 'method.protected'); + + expect(boundTraitMethodCallIgnoreExtension()->shouldIgnore($error, $node, $scope))->toBeFalse(); +}); + +test('does not ignore method calls on a variable other than $this', function (): void { + $file = traitOnlyFixtureFile(); + $node = new MethodCall(new Variable('other'), new Identifier('helperMethod')); + $scope = $this->createStub(Scope::class); + $scope->method('getFile')->willReturn($file); + + expect(boundTraitMethodCallIgnoreExtension()->shouldIgnore(methodNotFoundError($file), $node, $scope))->toBeFalse(); +});