Skip to content

Commit 411daa4

Browse files
committed
Compute ConstantArrayType sealed-shape isSuperTypeOf() reasons lazily via IsSuperTypeOfResult closures
- Add an optional `list<Closure(): string> $lazyReasons` channel to `IsSuperTypeOfResult` plus a `getReasons()` accessor that materializes and de-duplicates them; `and()`, `or()`, `decorateReasons()`, `extremeIdentity()`, `maxMin()`, `lazyMaxMin()`, `negate()` and `toAcceptsResult()` all propagate the lazy reasons untouched. - `ConstantArrayType::isSuperTypeOf()` now passes the "Sealed array shapes … cannot be intersected" explanation as a lazy closure instead of eagerly calling `describe()` on both shapes for every sealed-vs-sealed comparison. On `tests/bench/data/bug-7581.php` this removed 22.5k wasted `describe()` builds (the reason was never rendered) and cut analysis time ~35% (≈693ms → ≈452ms in-process), erasing the regression from the "reasons" batch. - Materialize the lazy reasons at the render sites that surface them as tips: `TypeCombinator::intersect()` (both NeverType-with-reason branches), `InitializerExprTypeResolver::resolveIdenticalType()`, `ImpossibleCheckTypeHelper` and `ImpossibleInstanceOfRule`, so error output is unchanged. - Probed the sibling `accepts()` / `AcceptsResult` describe-based reasons in `ConstantArrayType`: they only run on genuine acceptance failures (0 builds on the benchmark), so they are not part of this regression and were left eager. The parallel `ObjectType` reasons were already addressed separately (0c88c32, 4be03a5).
1 parent 6807620 commit 411daa4

7 files changed

Lines changed: 96 additions & 17 deletions

File tree

src/Reflection/InitializerExprTypeResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1963,7 +1963,7 @@ public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResul
19631963
$leftIsSuperTypeOfRight = $leftType->isSuperTypeOf($rightType);
19641964
$rightIsSuperTypeOfLeft = $rightType->isSuperTypeOf($leftType);
19651965
if ($leftIsSuperTypeOfRight->no() && $rightIsSuperTypeOfLeft->no()) {
1966-
return new TypeResult(new ConstantBooleanType(false), array_merge($leftIsSuperTypeOfRight->reasons, $rightIsSuperTypeOfLeft->reasons));
1966+
return new TypeResult(new ConstantBooleanType(false), array_merge($leftIsSuperTypeOfRight->getReasons(), $rightIsSuperTypeOfLeft->getReasons()));
19671967
}
19681968

19691969
if ($leftType instanceof ConstantArrayType && $rightType instanceof ConstantArrayType) {

src/Rules/Classes/ImpossibleInstanceOfRule.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
8585
}
8686

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

9090
$addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder {
9191
if ($reasons !== []) {

src/Rules/Comparison/ImpossibleCheckTypeHelper.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ private function getSpecifiedType(
398398
continue;
399399
}
400400

401-
foreach ($isSuperType->reasons as $reason) {
401+
foreach ($isSuperType->getReasons() as $reason) {
402402
$reasons[] = $reason;
403403
}
404404
}
@@ -431,7 +431,7 @@ private function getSpecifiedType(
431431
continue;
432432
}
433433

434-
foreach ($isSuperType->reasons as $reason) {
434+
foreach ($isSuperType->getReasons() as $reason) {
435435
$reasons[] = $reason;
436436
}
437437
}

src/Type/Constant/ConstantArrayType.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
766766
if ($hasOffset->no()) {
767767
if (!$this->isOptionalKey($i)) {
768768
if ($thisUnsealedness->no() && $typeUnsealedness->no()) {
769-
return IsSuperTypeOfResult::createNo([$this->sealedArrayShapesCannotBeIntersectedReason($type)]);
769+
return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
770770
}
771771
return IsSuperTypeOfResult::createNo();
772772
}
@@ -803,7 +803,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
803803
if ($thisUnsealedness->no()) {
804804
if (!$type->isOptionalKey($i)) {
805805
if ($typeUnsealedness->no()) {
806-
return IsSuperTypeOfResult::createNo([$this->sealedArrayShapesCannotBeIntersectedReason($type)]);
806+
return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
807807
}
808808
return IsSuperTypeOfResult::createNo();
809809
}
@@ -867,6 +867,11 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
867867
return IsSuperTypeOfResult::createNo();
868868
}
869869

870+
/**
871+
* Passed as a lazy reason to IsSuperTypeOfResult so the expensive describe() calls only
872+
* run when the reason is actually rendered, never during the hot isSuperTypeOf()
873+
* comparisons whose reasons are discarded.
874+
*/
870875
private function sealedArrayShapesCannotBeIntersectedReason(self $type): string
871876
{
872877
return sprintf(

src/Type/IsSuperTypeOfResult.php

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace PHPStan\Type;
44

5+
use Closure;
56
use PHPStan\ShouldNotHappenException;
67
use PHPStan\TrinaryLogic;
8+
use function array_map;
79
use function array_merge;
810
use function array_unique;
911
use function array_values;
@@ -23,6 +25,11 @@
2325
* This is distinct from `accepts()` which also considers rule levels and PHPDoc context.
2426
* Use `isSuperTypeOf()` for type-theoretic comparisons and `accepts()` for assignability checks.
2527
*
28+
* Reasons can also be provided lazily (as `Closure(): string`) via $lazyReasons. This lets a
29+
* type comparison attach an expensive-to-build explanation (e.g. one that calls describe() on
30+
* large array shapes) without paying for it on the hot path — the closure only runs when
31+
* getReasons() is called, i.e. when the reason is actually rendered.
32+
*
2633
* Can be converted to AcceptsResult via toAcceptsResult().
2734
*
2835
* @api
@@ -39,10 +46,12 @@ final class IsSuperTypeOfResult
3946
/**
4047
* @api
4148
* @param list<string> $reasons Human-readable explanations of the type relationship
49+
* @param list<Closure(): string> $lazyReasons Reasons built on demand, see the class docblock
4250
*/
4351
public function __construct(
4452
public readonly TrinaryLogic $result,
4553
public readonly array $reasons,
54+
public readonly array $lazyReasons = [],
4655
)
4756
{
4857
}
@@ -74,18 +83,39 @@ public function no(): bool
7483
return $this->result->no();
7584
}
7685

86+
/**
87+
* All reasons with the lazy ones materialized. Prefer this over reading $reasons directly
88+
* when the reasons are going to be rendered.
89+
*
90+
* @return list<string>
91+
*/
92+
public function getReasons(): array
93+
{
94+
if ($this->lazyReasons === []) {
95+
return $this->reasons;
96+
}
97+
98+
return array_values(array_unique(array_merge(
99+
$this->reasons,
100+
array_map(static fn (Closure $cb): string => $cb(), $this->lazyReasons),
101+
)));
102+
}
103+
77104
public static function createYes(): self
78105
{
79106
return self::$YES ??= new self(TrinaryLogic::createYes(), []);
80107
}
81108

82-
/** @param list<string> $reasons */
83-
public static function createNo(array $reasons = []): self
109+
/**
110+
* @param list<string> $reasons
111+
* @param list<Closure(): string> $lazyReasons
112+
*/
113+
public static function createNo(array $reasons = [], array $lazyReasons = []): self
84114
{
85-
if ($reasons === []) {
115+
if ($reasons === [] && $lazyReasons === []) {
86116
return self::$NO ??= new self(TrinaryLogic::createNo(), $reasons);
87117
}
88-
return new self(TrinaryLogic::createNo(), $reasons);
118+
return new self(TrinaryLogic::createNo(), $reasons, $lazyReasons);
89119
}
90120

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

104134
public function toAcceptsResult(): AcceptsResult
105135
{
106-
return new AcceptsResult($this->result, $this->reasons);
136+
return new AcceptsResult($this->result, $this->getReasons());
107137
}
108138

109139
public function and(self ...$others): self
110140
{
111141
$results = [];
112142
$reasons = [];
143+
$lazyReasons = [];
113144
foreach ($others as $other) {
114145
$results[] = $other->result;
115146
$reasons[] = $other->reasons;
147+
$lazyReasons[] = $other->lazyReasons;
116148
}
117149

118150
return new self(
119151
$this->result->and(...$results),
120152
array_values(array_unique(array_merge($this->reasons, ...$reasons))),
153+
array_merge($this->lazyReasons, ...$lazyReasons),
121154
);
122155
}
123156

124157
public function or(self ...$others): self
125158
{
126159
$results = [];
127160
$reasons = [];
161+
$lazyReasons = [];
128162
foreach ($others as $other) {
129163
$results[] = $other->result;
130164
$reasons[] = $other->reasons;
165+
$lazyReasons[] = $other->lazyReasons;
131166
}
132167

133168
return new self(
134169
$this->result->or(...$results),
135170
array_values(array_unique(array_merge($this->reasons, ...$reasons))),
171+
array_merge($this->lazyReasons, ...$lazyReasons),
136172
);
137173
}
138174

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

147-
return new self($this->result, $reasons);
183+
$lazyReasons = [];
184+
foreach ($this->lazyReasons as $lazyReason) {
185+
$lazyReasons[] = static fn (): string => $cb($lazyReason());
186+
}
187+
188+
return new self($this->result, $reasons, $lazyReasons);
148189
}
149190

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

157198
$results = [];
158199
$reasons = [];
200+
$lazyReasons = [];
159201
foreach ($operands as $operand) {
160202
$results[] = $operand->result;
161203
foreach ($operand->reasons as $reason) {
162204
$reasons[] = $reason;
163205
}
206+
foreach ($operand->lazyReasons as $lazyReason) {
207+
$lazyReasons[] = $lazyReason;
208+
}
164209
}
165210

166-
return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)));
211+
return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons);
167212
}
168213

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

176221
$results = [];
177222
$reasons = [];
223+
$lazyReasons = [];
178224
foreach ($operands as $operand) {
179225
$results[] = $operand->result;
180226
foreach ($operand->reasons as $reason) {
181227
$reasons[] = $reason;
182228
}
229+
foreach ($operand->lazyReasons as $lazyReason) {
230+
$lazyReasons[] = $lazyReason;
231+
}
183232
}
184233

185-
return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)));
234+
return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons);
186235
}
187236

188237
/**
@@ -196,6 +245,7 @@ public static function lazyMaxMin(
196245
): self
197246
{
198247
$reasons = [];
248+
$lazyReasons = [];
199249
$hasNo = false;
200250
foreach ($objects as $object) {
201251
$isSuperTypeOf = $callback($object);
@@ -208,17 +258,21 @@ public static function lazyMaxMin(
208258
foreach ($isSuperTypeOf->reasons as $reason) {
209259
$reasons[] = $reason;
210260
}
261+
foreach ($isSuperTypeOf->lazyReasons as $lazyReason) {
262+
$lazyReasons[] = $lazyReason;
263+
}
211264
}
212265

213266
return new self(
214267
$hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(),
215268
array_values(array_unique($reasons)),
269+
$lazyReasons,
216270
);
217271
}
218272

219273
public function negate(): self
220274
{
221-
return new self($this->result->negate(), $this->reasons);
275+
return new self($this->result->negate(), $this->reasons, $this->lazyReasons);
222276
}
223277

224278
public function describe(): string

src/Type/TypeCombinator.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,7 +1957,7 @@ public static function intersect(Type ...$types): Type
19571957
$merged = self::intersectDefiniteConstantArrays($constArray, $otherArray);
19581958
if ($merged instanceof NeverType) {
19591959
if ($merged->getReason() === null) {
1960-
$reasons = array_merge($isSuperTypeA->reasons, $isSuperTypeB->reasons);
1960+
$reasons = array_merge($isSuperTypeA->getReasons(), $isSuperTypeB->getReasons());
19611961
if ($reasons !== []) {
19621962
return new NeverType(reason: $reasons[0]);
19631963
}
@@ -2054,7 +2054,7 @@ public static function intersect(Type ...$types): Type
20542054
}
20552055

20562056
if ($isSuperTypeA->no()) {
2057-
return new NeverType(reason: $isSuperTypeA->reasons[0] ?? null);
2057+
return new NeverType(reason: $isSuperTypeA->getReasons()[0] ?? null);
20582058
}
20592059
}
20602060
}

tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,26 @@ public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResul
10311031
);
10321032
}
10331033

1034+
public function testSealedArrayShapesCannotBeIntersectedReasonIsLazy(): void
1035+
{
1036+
$resolver = self::getContainer()->getByType(TypeStringResolver::class);
1037+
1038+
[$type, $otherType] = BleedingEdgeToggle::withBleedingEdge(true, static fn (): array => [
1039+
$resolver->resolve('array{foo: int}', null),
1040+
$resolver->resolve('array{bar: string}', null),
1041+
]);
1042+
1043+
$result = $type->isSuperTypeOf($otherType);
1044+
$this->assertTrue($result->no());
1045+
1046+
// The expensive describe()-based reason must not be built eagerly during the
1047+
// isSuperTypeOf() hot path - it is only materialized on demand via getReasons().
1048+
$this->assertSame([], $result->reasons);
1049+
$this->assertSame([
1050+
'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',
1051+
], $result->getReasons());
1052+
}
1053+
10341054
public static function dataInferTemplateTypes(): array
10351055
{
10361056
$templateType = static fn ($name): Type => TemplateTypeFactory::create(

0 commit comments

Comments
 (0)