diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 5599286bb8a..9e68af55371 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -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) { diff --git a/src/Rules/Classes/ImpossibleInstanceOfRule.php b/src/Rules/Classes/ImpossibleInstanceOfRule.php index 314aa2911c5..abe66acfdfa 100644 --- a/src/Rules/Classes/ImpossibleInstanceOfRule.php +++ b/src/Rules/Classes/ImpossibleInstanceOfRule.php @@ -85,7 +85,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr); - $reasons = $classType->isSuperTypeOf($exprType)->reasons; + $reasons = $classType->isSuperTypeOf($exprType)->getReasons(); $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { if ($reasons !== []) { diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index f729683ecab..13f62f78cb6 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -398,7 +398,7 @@ private function getSpecifiedType( continue; } - foreach ($isSuperType->reasons as $reason) { + foreach ($isSuperType->getReasons() as $reason) { $reasons[] = $reason; } } @@ -431,7 +431,7 @@ private function getSpecifiedType( continue; } - foreach ($isSuperType->reasons as $reason) { + foreach ($isSuperType->getReasons() as $reason) { $reasons[] = $reason; } } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 82a3d074ea6..7eca2eaa587 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -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(); } @@ -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(); } @@ -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( diff --git a/src/Type/IsSuperTypeOfResult.php b/src/Type/IsSuperTypeOfResult.php index dc1703bb511..674add634e3 100644 --- a/src/Type/IsSuperTypeOfResult.php +++ b/src/Type/IsSuperTypeOfResult.php @@ -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; @@ -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 @@ -39,10 +46,12 @@ final class IsSuperTypeOfResult /** * @api * @param list $reasons Human-readable explanations of the type relationship + * @param list $lazyReasons Reasons built on demand, see the class docblock */ public function __construct( public readonly TrinaryLogic $result, public readonly array $reasons, + public readonly array $lazyReasons = [], ) { } @@ -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 + */ + 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 $reasons */ - public static function createNo(array $reasons = []): self + /** + * @param list $reasons + * @param list $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 @@ -103,21 +133,24 @@ 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), ); } @@ -125,14 +158,17 @@ 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), ); } @@ -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() */ @@ -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() */ @@ -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); } /** @@ -196,6 +245,7 @@ public static function lazyMaxMin( ): self { $reasons = []; + $lazyReasons = []; $hasNo = false; foreach ($objects as $object) { $isSuperTypeOf = $callback($object); @@ -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 diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8043afa837d..00b298e8c7f 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -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]); } @@ -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); } } } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index bf7ee440941..5c85f860264 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -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(