Skip to content

Commit b98abe0

Browse files
committed
Rework match expression analysis with enum with performance in mind
1 parent 344c21f commit b98abe0

File tree

6 files changed

+1061
-13
lines changed

6 files changed

+1061
-13
lines changed

src/Analyser/MutatingScope.php

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,10 +1635,92 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
16351635
return $generatorReturnType;
16361636
} elseif ($node instanceof Expr\Match_) {
16371637
$cond = $node->cond;
1638+
$condType = $this->getType($cond);
16381639
$types = [];
16391640

16401641
$matchScope = $this;
1641-
foreach ($node->arms as $arm) {
1642+
$arms = $node->arms;
1643+
if ($condType->isEnum()->yes()) {
1644+
// enum match analysis would work even without this if branch
1645+
// but would be much slower
1646+
// this avoids using ObjectType::$subtractedType which is slow for huge enums
1647+
// because of repeated union type normalization
1648+
$enumCases = $condType->getEnumCases();
1649+
if (count($enumCases) > 0) {
1650+
$indexedEnumCases = [];
1651+
foreach ($enumCases as $enumCase) {
1652+
$indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase;
1653+
}
1654+
$unusedIndexedEnumCases = $indexedEnumCases;
1655+
1656+
foreach ($arms as $i => $arm) {
1657+
if ($arm->conds === null) {
1658+
continue;
1659+
}
1660+
1661+
$conditionCases = [];
1662+
foreach ($arm->conds as $armCond) {
1663+
if (!$armCond instanceof Expr\ClassConstFetch) {
1664+
continue 2;
1665+
}
1666+
if (!$armCond->class instanceof Name) {
1667+
continue 2;
1668+
}
1669+
if (!$armCond->name instanceof Node\Identifier) {
1670+
continue 2;
1671+
}
1672+
$fetchedClassName = $this->resolveName($armCond->class);
1673+
$loweredFetchedClassName = strtolower($fetchedClassName);
1674+
if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) {
1675+
continue 2;
1676+
}
1677+
1678+
$caseName = $armCond->name->toString();
1679+
if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) {
1680+
continue 2;
1681+
}
1682+
1683+
$conditionCases[] = $indexedEnumCases[$loweredFetchedClassName][$caseName];
1684+
unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]);
1685+
}
1686+
1687+
$conditionCasesCount = count($conditionCases);
1688+
if ($conditionCasesCount === 0) {
1689+
throw new ShouldNotHappenException();
1690+
} elseif ($conditionCasesCount === 1) {
1691+
$conditionCaseType = $conditionCases[0];
1692+
} else {
1693+
$conditionCaseType = new UnionType($conditionCases);
1694+
}
1695+
1696+
$types[] = $matchScope->addTypeToExpression(
1697+
$cond,
1698+
$conditionCaseType,
1699+
)->getType($arm->body);
1700+
unset($arms[$i]);
1701+
}
1702+
1703+
$remainingCases = [];
1704+
foreach ($unusedIndexedEnumCases as $cases) {
1705+
foreach ($cases as $case) {
1706+
$remainingCases[] = $case;
1707+
}
1708+
}
1709+
1710+
$remainingCasesCount = count($remainingCases);
1711+
if ($remainingCasesCount === 0) {
1712+
$remainingType = new NeverType();
1713+
} elseif ($remainingCasesCount === 1) {
1714+
$remainingType = $remainingCases[0];
1715+
} else {
1716+
$remainingType = new UnionType($remainingCases);
1717+
}
1718+
1719+
$matchScope = $matchScope->addTypeToExpression($cond, $remainingType);
1720+
}
1721+
}
1722+
1723+
foreach ($arms as $arm) {
16421724
if ($arm->conds === null) {
16431725
if ($node->hasAttribute(self::KEEP_VOID_ATTRIBUTE_NAME)) {
16441726
$arm->body->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, $node->getAttribute(self::KEEP_VOID_ATTRIBUTE_NAME));
@@ -3842,7 +3924,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType,
38423924
$nativeTypes = $scope->nativeExpressionTypes;
38433925
$nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty);
38443926

3845-
return $this->scopeFactory->create(
3927+
$scope = $this->scopeFactory->create(
38463928
$this->context,
38473929
$this->isDeclareStrictTypes(),
38483930
$this->getFunction(),
@@ -3860,6 +3942,12 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType,
38603942
$this->parentScope,
38613943
$this->nativeTypesPromoted,
38623944
);
3945+
3946+
if ($expr instanceof AlwaysRememberedExpr) {
3947+
return $scope->specifyExpressionType($expr->expr, $type, $nativeType, $certainty);
3948+
}
3949+
3950+
return $scope;
38633951
}
38643952

38653953
public function assignExpression(Expr $expr, Type $type, ?Type $nativeType = null): self
@@ -4074,7 +4162,7 @@ private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): se
40744162
);
40754163
}
40764164

4077-
private function addTypeToExpression(Expr $expr, Type $type): self
4165+
public function addTypeToExpression(Expr $expr, Type $type): self
40784166
{
40794167
$originalExprType = $this->getType($expr);
40804168
$nativeType = $this->getNativeType($expr);

src/Analyser/NodeScopeResolver.php

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,13 @@
203203
use function is_array;
204204
use function is_int;
205205
use function is_string;
206+
use function ksort;
206207
use function sprintf;
207208
use function str_starts_with;
208209
use function strtolower;
209210
use function trim;
210211
use const PHP_VERSION_ID;
212+
use const SORT_NUMERIC;
211213

212214
class NodeScopeResolver
213215
{
@@ -3372,6 +3374,7 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void {
33723374
$hasYield = true;
33733375
} elseif ($expr instanceof Expr\Match_) {
33743376
$deepContext = $context->enterDeep();
3377+
$condType = $scope->getType($expr->cond);
33753378
$condResult = $this->processExprNode($stmt, $expr->cond, $scope, $nodeCallback, $deepContext);
33763379
$scope = $condResult->getScope();
33773380
$hasYield = $condResult->hasYield();
@@ -3381,11 +3384,137 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void {
33813384
$armNodes = [];
33823385
$hasDefaultCond = false;
33833386
$hasAlwaysTrueCond = false;
3384-
foreach ($expr->arms as $arm) {
3387+
$arms = $expr->arms;
3388+
if ($condType->isEnum()->yes()) {
3389+
// enum match analysis would work even without this if branch
3390+
// but would be much slower
3391+
// this avoids using ObjectType::$subtractedType which is slow for huge enums
3392+
// because of repeated union type normalization
3393+
$enumCases = $condType->getEnumCases();
3394+
if (count($enumCases) > 0) {
3395+
$indexedEnumCases = [];
3396+
foreach ($enumCases as $enumCase) {
3397+
$indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase;
3398+
}
3399+
$unusedIndexedEnumCases = $indexedEnumCases;
3400+
foreach ($arms as $i => $arm) {
3401+
if ($arm->conds === null) {
3402+
continue;
3403+
}
3404+
3405+
$condNodes = [];
3406+
$conditionCases = [];
3407+
foreach ($arm->conds as $cond) {
3408+
if (!$cond instanceof Expr\ClassConstFetch) {
3409+
continue 2;
3410+
}
3411+
if (!$cond->class instanceof Name) {
3412+
continue 2;
3413+
}
3414+
if (!$cond->name instanceof Node\Identifier) {
3415+
continue 2;
3416+
}
3417+
$fetchedClassName = $scope->resolveName($cond->class);
3418+
$loweredFetchedClassName = strtolower($fetchedClassName);
3419+
if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) {
3420+
continue 2;
3421+
}
3422+
3423+
if (!array_key_exists($loweredFetchedClassName, $unusedIndexedEnumCases)) {
3424+
throw new ShouldNotHappenException();
3425+
}
3426+
3427+
$caseName = $cond->name->toString();
3428+
if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) {
3429+
continue 2;
3430+
}
3431+
3432+
$enumCase = $indexedEnumCases[$loweredFetchedClassName][$caseName];
3433+
$conditionCases[] = $enumCase;
3434+
$armConditionScope = $matchScope;
3435+
if (!array_key_exists($caseName, $unusedIndexedEnumCases[$loweredFetchedClassName])) {
3436+
// force "always false"
3437+
$armConditionScope = $armConditionScope->removeTypeFromExpression(
3438+
$expr->cond,
3439+
$enumCase,
3440+
);
3441+
} elseif (count($unusedIndexedEnumCases[$loweredFetchedClassName]) === 1) {
3442+
$hasAlwaysTrueCond = true;
3443+
3444+
// force "always true"
3445+
$armConditionScope = $armConditionScope->addTypeToExpression(
3446+
$expr->cond,
3447+
$enumCase,
3448+
);
3449+
}
3450+
3451+
$this->processExprNode($stmt, $cond, $armConditionScope, $nodeCallback, $deepContext);
3452+
3453+
$condNodes[] = new MatchExpressionArmCondition(
3454+
$cond,
3455+
$armConditionScope,
3456+
$cond->getStartLine(),
3457+
);
3458+
3459+
unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]);
3460+
}
3461+
3462+
$conditionCasesCount = count($conditionCases);
3463+
if ($conditionCasesCount === 0) {
3464+
throw new ShouldNotHappenException();
3465+
} elseif ($conditionCasesCount === 1) {
3466+
$conditionCaseType = $conditionCases[0];
3467+
} else {
3468+
$conditionCaseType = new UnionType($conditionCases);
3469+
}
3470+
3471+
$matchArmBodyScope = $matchScope->addTypeToExpression(
3472+
$expr->cond,
3473+
$conditionCaseType,
3474+
);
3475+
$matchArmBody = new MatchExpressionArmBody($matchArmBodyScope, $arm->body);
3476+
$armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine());
3477+
3478+
$armResult = $this->processExprNode(
3479+
$stmt,
3480+
$arm->body,
3481+
$matchArmBodyScope,
3482+
$nodeCallback,
3483+
ExpressionContext::createTopLevel(),
3484+
);
3485+
$armScope = $armResult->getScope();
3486+
$scope = $scope->mergeWith($armScope);
3487+
$hasYield = $hasYield || $armResult->hasYield();
3488+
$throwPoints = array_merge($throwPoints, $armResult->getThrowPoints());
3489+
$impurePoints = array_merge($impurePoints, $armResult->getImpurePoints());
3490+
3491+
unset($arms[$i]);
3492+
}
3493+
3494+
$remainingCases = [];
3495+
foreach ($unusedIndexedEnumCases as $cases) {
3496+
foreach ($cases as $case) {
3497+
$remainingCases[] = $case;
3498+
}
3499+
}
3500+
3501+
$remainingCasesCount = count($remainingCases);
3502+
if ($remainingCasesCount === 0) {
3503+
$remainingType = new NeverType();
3504+
} elseif ($remainingCasesCount === 1) {
3505+
$remainingType = $remainingCases[0];
3506+
} else {
3507+
$remainingType = new UnionType($remainingCases);
3508+
}
3509+
3510+
$matchScope = $matchScope->addTypeToExpression($expr->cond, $remainingType);
3511+
}
3512+
}
3513+
foreach ($arms as $i => $arm) {
33853514
if ($arm->conds === null) {
33863515
$hasDefaultCond = true;
33873516
$matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body);
3388-
$armNodes[] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine());
3517+
$armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine());
33893518
$armResult = $this->processExprNode($stmt, $arm->body, $matchScope, $nodeCallback, ExpressionContext::createTopLevel());
33903519
$matchScope = $armResult->getScope();
33913520
$hasYield = $hasYield || $armResult->hasYield();
@@ -3438,7 +3567,7 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void {
34383567
$bodyScope = $this->processExprNode($stmt, $filteringExpr, $matchScope, static function (): void {
34393568
}, $deepContext)->getTruthyScope();
34403569
$matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body);
3441-
$armNodes[] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine());
3570+
$armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine());
34423571

34433572
$armResult = $this->processExprNode(
34443573
$stmt,
@@ -3460,7 +3589,9 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void {
34603589
$throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(UnhandledMatchError::class), $expr, false);
34613590
}
34623591

3463-
$nodeCallback(new MatchExpressionNode($expr->cond, $armNodes, $expr, $matchScope), $scope);
3592+
ksort($armNodes, SORT_NUMERIC);
3593+
3594+
$nodeCallback(new MatchExpressionNode($expr->cond, array_values($armNodes), $expr, $matchScope), $scope);
34643595
} elseif ($expr instanceof AlwaysRememberedExpr) {
34653596
$result = $this->processExprNode($stmt, $expr->getExpr(), $scope, $nodeCallback, $context);
34663597
$hasYield = $result->hasYield();

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,16 @@ public function testBug10867(): void
13731373
$this->assertNoErrors($errors);
13741374
}
13751375

1376+
public function testBug11263(): void
1377+
{
1378+
if (PHP_VERSION_ID < 80100) {
1379+
$this->markTestSkipped('Test requires PHP 8.1.');
1380+
}
1381+
1382+
$errors = $this->runAnalyse(__DIR__ . '/data/bug-11263.php');
1383+
$this->assertNoErrors($errors);
1384+
}
1385+
13761386
public function testBug11147(): void
13771387
{
13781388
if (PHP_VERSION_ID < 80000) {

0 commit comments

Comments
 (0)