Skip to content
Open
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 @@ -84,6 +84,11 @@ services:
tags:
- phpstan.ignoreErrorExtension

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

-
class: Pest\PHPStan\Rules\InvalidThrowsExceptionRule
arguments:
Expand Down
61 changes: 61 additions & 0 deletions src/Type/Pest/BoundTraitMethodCallIgnoreExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\IgnoreErrorExtension;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;

final class BoundTraitMethodCallIgnoreExtension implements IgnoreErrorExtension
{
public function __construct(
private readonly PestConfigReader $pestConfigReader,
private readonly ReflectionProvider $reflectionProvider,
) {}

public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
{
if ($error->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<string>
*/
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(),
));
}
}
77 changes: 77 additions & 0 deletions tests/Type/BoundTraitMethodCallIgnoreExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

use Pest\PHPStan\Type\Pest\BoundTraitMethodCallIgnoreExtension;
use Pest\PHPStan\Type\Pest\PestConfigReader;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use Tests\TestCase;

function boundTraitMethodCallIgnoreExtension(): BoundTraitMethodCallIgnoreExtension
{
return new BoundTraitMethodCallIgnoreExtension(
TestCase::getContainer()->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();
});