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
2 changes: 1 addition & 1 deletion src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1963,7 +1963,7 @@ public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResul
$leftIsSuperTypeOfRight = $leftType->isSuperTypeOf($rightType);
$rightIsSuperTypeOfLeft = $rightType->isSuperTypeOf($leftType);
if ($leftIsSuperTypeOfRight->no() && $rightIsSuperTypeOfLeft->no()) {
return new TypeResult(new ConstantBooleanType(false), array_merge($leftIsSuperTypeOfRight->reasons, $rightIsSuperTypeOfLeft->reasons));
return new TypeResult(new ConstantBooleanType(false), array_merge($leftIsSuperTypeOfRight->getReasons(), $rightIsSuperTypeOfLeft->getReasons()));
}

if ($leftType instanceof ConstantArrayType && $rightType instanceof ConstantArrayType) {
Expand Down
2 changes: 1 addition & 1 deletion src/Rules/Classes/ImpossibleInstanceOfRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
}

$exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr);
$reasons = $classType->isSuperTypeOf($exprType)->reasons;
$reasons = $classType->isSuperTypeOf($exprType)->getReasons();

Check warning on line 88 in src/Rules/Classes/ImpossibleInstanceOfRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ } $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr); - $reasons = $classType->isSuperTypeOf($exprType)->getReasons(); + $reasons = $exprType->isSuperTypeOf($classType)->getReasons(); $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { if ($reasons !== []) {

$addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder {
if ($reasons !== []) {
Expand Down
4 changes: 2 additions & 2 deletions src/Rules/Comparison/ImpossibleCheckTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ private function getSpecifiedType(
continue;
}

foreach ($isSuperType->reasons as $reason) {
foreach ($isSuperType->getReasons() as $reason) {
$reasons[] = $reason;
}
}
Expand Down Expand Up @@ -431,7 +431,7 @@ private function getSpecifiedType(
continue;
}

foreach ($isSuperType->reasons as $reason) {
foreach ($isSuperType->getReasons() as $reason) {
$reasons[] = $reason;
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
if ($hasOffset->no()) {
if (!$this->isOptionalKey($i)) {
if ($thisUnsealedness->no() && $typeUnsealedness->no()) {
return IsSuperTypeOfResult::createNo([$this->sealedArrayShapesCannotBeIntersectedReason($type)]);
return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
}
return IsSuperTypeOfResult::createNo();
}
Expand Down Expand Up @@ -803,7 +803,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
if ($thisUnsealedness->no()) {
if (!$type->isOptionalKey($i)) {
if ($typeUnsealedness->no()) {
return IsSuperTypeOfResult::createNo([$this->sealedArrayShapesCannotBeIntersectedReason($type)]);
return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
}
return IsSuperTypeOfResult::createNo();
}
Expand Down Expand Up @@ -867,6 +867,11 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
return IsSuperTypeOfResult::createNo();
}

/**
* Passed as a lazy reason to IsSuperTypeOfResult so the expensive describe() calls only
* run when the reason is actually rendered, never during the hot isSuperTypeOf()
* comparisons whose reasons are discarded.
*/
private function sealedArrayShapesCannotBeIntersectedReason(self $type): string
{
return sprintf(
Expand Down
72 changes: 63 additions & 9 deletions src/Type/IsSuperTypeOfResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace PHPStan\Type;

use Closure;
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
Expand All @@ -23,6 +25,11 @@
* This is distinct from `accepts()` which also considers rule levels and PHPDoc context.
* Use `isSuperTypeOf()` for type-theoretic comparisons and `accepts()` for assignability checks.
*
* Reasons can also be provided lazily (as `Closure(): string`) via $lazyReasons. This lets a
* type comparison attach an expensive-to-build explanation (e.g. one that calls describe() on
* large array shapes) without paying for it on the hot path — the closure only runs when
* getReasons() is called, i.e. when the reason is actually rendered.
*
* Can be converted to AcceptsResult via toAcceptsResult().
*
* @api
Expand All @@ -39,10 +46,12 @@ final class IsSuperTypeOfResult
/**
* @api
* @param list<string> $reasons Human-readable explanations of the type relationship
* @param list<Closure(): string> $lazyReasons Reasons built on demand, see the class docblock
*/
public function __construct(
public readonly TrinaryLogic $result,
public readonly array $reasons,
public readonly array $lazyReasons = [],
)
{
}
Expand Down Expand Up @@ -74,18 +83,39 @@ public function no(): bool
return $this->result->no();
}

/**
* All reasons with the lazy ones materialized. Prefer this over reading $reasons directly
* when the reasons are going to be rendered.
*
* @return list<string>
*/
public function getReasons(): array
{
if ($this->lazyReasons === []) {
return $this->reasons;
}

return array_values(array_unique(array_merge(
$this->reasons,
array_map(static fn (Closure $cb): string => $cb(), $this->lazyReasons),
)));
}

public static function createYes(): self
{
return self::$YES ??= new self(TrinaryLogic::createYes(), []);
}

/** @param list<string> $reasons */
public static function createNo(array $reasons = []): self
/**
* @param list<string> $reasons
* @param list<Closure(): string> $lazyReasons
*/
public static function createNo(array $reasons = [], array $lazyReasons = []): self
{
if ($reasons === []) {
if ($reasons === [] && $lazyReasons === []) {
return self::$NO ??= new self(TrinaryLogic::createNo(), $reasons);
}
return new self(TrinaryLogic::createNo(), $reasons);
return new self(TrinaryLogic::createNo(), $reasons, $lazyReasons);
}

public static function createMaybe(): self
Expand All @@ -103,36 +133,42 @@ public static function createFromBoolean(bool $value): self

public function toAcceptsResult(): AcceptsResult
{
return new AcceptsResult($this->result, $this->reasons);
return new AcceptsResult($this->result, $this->getReasons());
}

public function and(self ...$others): self
{
$results = [];
$reasons = [];
$lazyReasons = [];
foreach ($others as $other) {
$results[] = $other->result;
$reasons[] = $other->reasons;
$lazyReasons[] = $other->lazyReasons;
}

return new self(
$this->result->and(...$results),
array_values(array_unique(array_merge($this->reasons, ...$reasons))),
array_merge($this->lazyReasons, ...$lazyReasons),
);
}

public function or(self ...$others): self
{
$results = [];
$reasons = [];
$lazyReasons = [];
foreach ($others as $other) {
$results[] = $other->result;
$reasons[] = $other->reasons;
$lazyReasons[] = $other->lazyReasons;
}

return new self(
$this->result->or(...$results),
array_values(array_unique(array_merge($this->reasons, ...$reasons))),
array_merge($this->lazyReasons, ...$lazyReasons),
);
}

Expand All @@ -144,7 +180,12 @@ public function decorateReasons(callable $cb): self
$reasons[] = $cb($reason);
}

return new self($this->result, $reasons);
$lazyReasons = [];
foreach ($this->lazyReasons as $lazyReason) {
$lazyReasons[] = static fn (): string => $cb($lazyReason());
}

return new self($this->result, $reasons, $lazyReasons);
}

/** @see TrinaryLogic::extremeIdentity() */
Expand All @@ -156,14 +197,18 @@ public static function extremeIdentity(self ...$operands): self

$results = [];
$reasons = [];
$lazyReasons = [];
foreach ($operands as $operand) {
$results[] = $operand->result;
foreach ($operand->reasons as $reason) {
$reasons[] = $reason;
}
foreach ($operand->lazyReasons as $lazyReason) {
$lazyReasons[] = $lazyReason;
}
}

return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)));
return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons);
}

/** @see TrinaryLogic::maxMin() */
Expand All @@ -175,14 +220,18 @@ public static function maxMin(self ...$operands): self

$results = [];
$reasons = [];
$lazyReasons = [];
foreach ($operands as $operand) {
$results[] = $operand->result;
foreach ($operand->reasons as $reason) {
$reasons[] = $reason;
}
foreach ($operand->lazyReasons as $lazyReason) {
$lazyReasons[] = $lazyReason;
}
}

return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)));
return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons);
}

/**
Expand All @@ -196,6 +245,7 @@ public static function lazyMaxMin(
): self
{
$reasons = [];
$lazyReasons = [];
$hasNo = false;
foreach ($objects as $object) {
$isSuperTypeOf = $callback($object);
Expand All @@ -208,17 +258,21 @@ public static function lazyMaxMin(
foreach ($isSuperTypeOf->reasons as $reason) {
$reasons[] = $reason;
}
foreach ($isSuperTypeOf->lazyReasons as $lazyReason) {
$lazyReasons[] = $lazyReason;
}
}

return new self(
$hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(),
array_values(array_unique($reasons)),
$lazyReasons,
);
}

public function negate(): self
{
return new self($this->result->negate(), $this->reasons);
return new self($this->result->negate(), $this->reasons, $this->lazyReasons);
}

public function describe(): string
Expand Down
4 changes: 2 additions & 2 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -1957,7 +1957,7 @@ public static function intersect(Type ...$types): Type
$merged = self::intersectDefiniteConstantArrays($constArray, $otherArray);
if ($merged instanceof NeverType) {
if ($merged->getReason() === null) {
$reasons = array_merge($isSuperTypeA->reasons, $isSuperTypeB->reasons);
$reasons = array_merge($isSuperTypeA->getReasons(), $isSuperTypeB->getReasons());
if ($reasons !== []) {
return new NeverType(reason: $reasons[0]);
}
Expand Down Expand Up @@ -2054,7 +2054,7 @@ public static function intersect(Type ...$types): Type
}

if ($isSuperTypeA->no()) {
return new NeverType(reason: $isSuperTypeA->reasons[0] ?? null);
return new NeverType(reason: $isSuperTypeA->getReasons()[0] ?? null);
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,26 @@ public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResul
);
}

public function testSealedArrayShapesCannotBeIntersectedReasonIsLazy(): void
{
$resolver = self::getContainer()->getByType(TypeStringResolver::class);

[$type, $otherType] = BleedingEdgeToggle::withBleedingEdge(true, static fn (): array => [
$resolver->resolve('array{foo: int}', null),
$resolver->resolve('array{bar: string}', null),
]);

$result = $type->isSuperTypeOf($otherType);
$this->assertTrue($result->no());

// The expensive describe()-based reason must not be built eagerly during the
// isSuperTypeOf() hot path - it is only materialized on demand via getReasons().
$this->assertSame([], $result->reasons);
$this->assertSame([
'Sealed array shapes array{foo: int} and array{bar: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys',
], $result->getReasons());
}

public static function dataInferTemplateTypes(): array
{
$templateType = static fn ($name): Type => TemplateTypeFactory::create(
Expand Down
Loading