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
13 changes: 13 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ services:
-
class: Pest\PHPStan\Type\Pest\PestConfigReader

-
class: Pest\PHPStan\Analysis\Expectation\ExpectationChainSubjectResolver

-
class: Pest\PHPStan\Analysis\Expectation\ExpectationMatcherRegistry

Expand All @@ -36,6 +39,11 @@ services:
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension

-
class: Pest\PHPStan\Type\Pest\ExpectationInstanceTypeSpecifyingExtension
tags:
- phpstan.typeSpecifier.methodTypeSpecifyingExtension

-
class: Pest\PHPStan\Type\Pest\OppositeExpectationMethodReturnTypeExtension
tags:
Expand Down Expand Up @@ -64,6 +72,11 @@ services:
tags:
- phpstan.broker.expressionTypeResolverExtension

-
class: Pest\PHPStan\Type\Pest\ExpectationChainSubjectNarrowingExtension
tags:
- phpstan.broker.expressionTypeResolverExtension

-
class: Pest\PHPStan\Type\Pest\PestInternalClassAccessIgnoreExtension
tags:
Expand Down
39 changes: 39 additions & 0 deletions src/Analysis/Expectation/ExpectationChainSubjectResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Analysis\Expectation;

use PhpParser\Node\Expr;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;

final class ExpectationChainSubjectResolver
{
public function subjectIntroducedBy(Expr $receiver): ?Expr
{
if ($receiver instanceof MethodCall) {
if (! $receiver->name instanceof Identifier || $receiver->name->toString() !== 'and') {
return null;
}

$args = $receiver->getArgs();

return $args === [] ? null : $args[0]->value;
}

if ($receiver instanceof FuncCall) {
if (! $receiver->name instanceof Name || $receiver->name->toString() !== 'expect') {
return null;
}

$args = $receiver->getArgs();

return $args === [] ? null : $args[0]->value;
}

return null;
}
}
153 changes: 153 additions & 0 deletions src/Type/Pest/ExpectationChainSubjectNarrowingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\PHPStan\Analysis\Expectation\ExpectationChainSubjectResolver;
use Pest\PHPStan\Analysis\Expectation\ExpectationMatcherRegistry;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\Expression as ExpressionStmt;
use PhpParser\NodeFinder;
use PhpParser\PrettyPrinter\Standard;
use PHPStan\Analyser\Scope;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;

final class ExpectationChainSubjectNarrowingExtension implements ExpressionTypeResolverExtension
{
private readonly Standard $printer;

/** @var array<string, list<array{0: string, 1: MethodCall, 2: int, 3: int}>> file path => list of [printed subject, toBeInstanceOf() call, enclosing statement start pos, end pos] */
private array $chainFactsCache = [];

public function __construct(
private readonly PestFileDiscoverer $fileDiscoverer,
private readonly ExpectationMatcherRegistry $matcherRegistry,
private readonly ExpectationChainSubjectResolver $subjectResolver,
) {
$this->printer = new Standard;
}

public function getType(Expr $expr, Scope $scope): ?Type
{
if (! $expr instanceof Variable && ! $expr instanceof ArrayDimFetch && ! $expr instanceof PropertyFetch) {
return null;
}

$exprStart = $expr->getStartFilePos();
if ($exprStart < 0) {
return null;
}

$facts = $this->chainFactsFor($scope->getFile());
if ($facts === []) {
return null;
}

$printedExpr = $this->printer->prettyPrintExpr($expr);

$narrowedType = null;

foreach ($facts as [$subjectPrint, $toBeInstanceOfCall, $stmtStart, $stmtEnd]) {
if ($subjectPrint !== $printedExpr) {
continue;
}

if ($exprStart < $stmtStart) {
continue;
}

if ($exprStart > $stmtEnd) {
continue;
}

if ($toBeInstanceOfCall->getEndFilePos() >= $exprStart) {
continue;
}

$assertedType = $this->matcherRegistry->assertedTypeFor('toBeInstanceOf', $toBeInstanceOfCall, $scope);
if (! $assertedType instanceof Type) {
continue;
}

$narrowedType = $narrowedType instanceof Type
? TypeCombinator::intersect($narrowedType, $assertedType)
: $assertedType;
}

return $narrowedType;
}

/**
* @return list<array{0: string, 1: MethodCall, 2: int, 3: int}>
*/
private function chainFactsFor(string $filePath): array
{
if (isset($this->chainFactsCache[$filePath])) {
return $this->chainFactsCache[$filePath];
}

$parsed = $this->fileDiscoverer->parseFile($filePath);
if ($parsed === null) {
return $this->chainFactsCache[$filePath] = [];
}

[$stmts] = $parsed;

$facts = [];

$nodeFinder = new NodeFinder;

/** @var ExpressionStmt[] $expressionStmts */
$expressionStmts = $nodeFinder->findInstanceOf($stmts, ExpressionStmt::class);

foreach ($expressionStmts as $stmt) {
$stmtStart = $stmt->getStartFilePos();
$stmtEnd = $stmt->getEndFilePos();
if ($stmtStart < 0) {
continue;
}

if ($stmtEnd < 0) {
continue;
}

$current = $stmt->expr;

while ($current instanceof MethodCall) {
$fact = $this->factFor($current, $stmtStart, $stmtEnd);
if ($fact !== null) {
$facts[] = $fact;
}

$current = $current->var;
}
}

return $this->chainFactsCache[$filePath] = $facts;
}

/**
* @return array{0: string, 1: MethodCall, 2: int, 3: int}|null
*/
private function factFor(MethodCall $methodCall, int $stmtStart, int $stmtEnd): ?array
{
if (! $methodCall->name instanceof Identifier || $methodCall->name->toString() !== 'toBeInstanceOf') {
return null;
}

$subjectExpr = $this->subjectResolver->subjectIntroducedBy($methodCall->var);
if (! $subjectExpr instanceof Expr) {
return null;
}

return [$this->printer->prettyPrintExpr($subjectExpr), $methodCall, $stmtStart, $stmtEnd];
}
}
98 changes: 98 additions & 0 deletions src/Type/Pest/ExpectationInstanceTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\Expectation;
use Pest\PHPStan\Analysis\Expectation\ExpectationChainSubjectResolver;
use Pest\PHPStan\Analysis\Expectation\ExpectationMatcherRegistry;
use Pest\PHPStan\Analysis\Expectation\ExpectationTypeNarrower;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\MethodTypeSpecifyingExtension;
use PHPStan\Type\Type;

final class ExpectationInstanceTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
{
private TypeSpecifier $typeSpecifier;

public function __construct(
private readonly ExpectationMatcherRegistry $matcherRegistry,
private readonly ExpectationTypeNarrower $typeNarrower,
private readonly ExpectationChainSubjectResolver $subjectResolver,
) {}

public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
{
$this->typeSpecifier = $typeSpecifier;
}

public function getClass(): string
{
return Expectation::class;
}

public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool
{
if (! $context->null()) {
return false;
}

return $this->collectFacts($node) !== [];
}

public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
{
$result = new SpecifiedTypes([], []);

foreach ($this->collectFacts($node) as [$subjectExpr, $toBeInstanceOfCall]) {
$assertedType = $this->matcherRegistry->assertedTypeFor('toBeInstanceOf', $toBeInstanceOfCall, $scope);
if (! $assertedType instanceof Type) {
continue;
}

$incomingType = $scope->getType($subjectExpr);
if (! $this->typeNarrower->hasOverlap($incomingType, $assertedType)) {
continue;
}

$narrowedType = $this->typeNarrower->narrow($incomingType, $assertedType);

$result = $result->unionWith(
$this->typeSpecifier->create($subjectExpr, $narrowedType, TypeSpecifierContext::createTrue(), $scope)
);
}

return $result;
}

/**
* @return list<array{0: Expr, 1: MethodCall}>
*/
private function collectFacts(MethodCall $node): array
{
$facts = [];
$current = $node;

while ($current instanceof MethodCall) {
if ($current->name instanceof Identifier && $current->name->toString() === 'toBeInstanceOf') {
$subjectExpr = $this->subjectResolver->subjectIntroducedBy($current->var);
if ($subjectExpr instanceof Expr) {
$facts[] = [$subjectExpr, $current];
}
}

$current = $current->var;
}

return $facts;
}
}
12 changes: 12 additions & 0 deletions tests/Type/ExpectTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@
yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-hook-properties.php');
});

test('expectation instance narrowing types', function (string $assertType, string $file, mixed ...$args): void {
$this->assertFileAsserts($assertType, $file, ...$args);
})->with(function (): Iterator {
yield from TestCase::gatherAssertTypes(__DIR__.'/data/expectation-instance-narrowing.php');
});

test('expectation chain subject narrowing types', function (string $assertType, string $file, mixed ...$args): void {
$this->assertFileAsserts($assertType, $file, ...$args);
})->with(function (): Iterator {
yield from TestCase::gatherAssertTypes(__DIR__.'/data/expectation-chain-subject-narrowing.php');
});

test('test call chain method types', function (string $assertType, string $file, mixed ...$args): void {
$this->assertFileAsserts($assertType, $file, ...$args);
})->with(function (): Iterator {
Expand Down
Loading