Skip to content

Commit df03be2

Browse files
ondrejmirtesclaude
andcommitted
Avoid 2^N getAllArrays() expansion in implode() return type extension
Process keys incrementally instead of generating all power-set variants upfront via `getAllArrays()`. For each key, fork partial results for optional keys and bail early when the count exceeds `CALCULATE_SCALARS_LIMIT`. This avoids allocating 2^N `ConstantArrayType` objects for N optional keys. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b26d08c commit df03be2

File tree

2 files changed

+52
-21
lines changed

2 files changed

+52
-21
lines changed

src/Type/Php/ImplodeFunctionReturnTypeExtension.php

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
8-
use PHPStan\Internal\CombinationsHelper;
98
use PHPStan\Reflection\FunctionReflection;
109
use PHPStan\Reflection\InitializerExprTypeResolver;
1110
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
@@ -113,33 +112,45 @@ private function implode(Type $arrayType, Type $separatorType): Type
113112

114113
private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType): ?Type
115114
{
116-
$strings = [];
117-
foreach ($arrayType->getAllArrays() as $array) {
118-
$valueTypes = $array->getValueTypes();
119-
120-
$arrayValues = [];
121-
$combinationsCount = 1;
122-
foreach ($valueTypes as $valueType) {
123-
$constScalars = $valueType->getConstantScalarValues();
124-
if (count($constScalars) === 0) {
125-
return null;
126-
}
127-
$arrayValues[] = $constScalars;
128-
$combinationsCount *= count($constScalars);
115+
$sep = $separatorType->getValue();
116+
$valueTypes = $arrayType->getValueTypes();
117+
$limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT;
118+
119+
// Build implode results incrementally, processing one key at a time.
120+
// For optional keys, fork each partial result into with/without variants.
121+
// This avoids generating 2^N ConstantArrayType objects via getAllArrays().
122+
/** @var list<list<scalar>> $partials */
123+
$partials = [[]];
124+
125+
foreach ($valueTypes as $i => $valueType) {
126+
$constScalars = $valueType->getConstantScalarValues();
127+
if (count($constScalars) === 0) {
128+
return null;
129129
}
130130

131-
if ($combinationsCount > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) {
132-
return null;
131+
$isOptional = $arrayType->isOptionalKey($i);
132+
$newPartials = [];
133+
134+
foreach ($partials as $partial) {
135+
if ($isOptional) {
136+
$newPartials[] = $partial;
137+
}
138+
foreach ($constScalars as $scalar) {
139+
$newPartial = $partial;
140+
$newPartial[] = $scalar;
141+
$newPartials[] = $newPartial;
142+
}
133143
}
134144

135-
$combinations = CombinationsHelper::combinations($arrayValues);
136-
foreach ($combinations as $combination) {
137-
$strings[] = new ConstantStringType(implode($separatorType->getValue(), $combination));
145+
$partials = $newPartials;
146+
if (count($partials) > $limit) {
147+
return null;
138148
}
139149
}
140150

141-
if (count($strings) > InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT) {
142-
return null;
151+
$strings = [];
152+
foreach ($partials as $partial) {
153+
$strings[] = new ConstantStringType(implode($sep, $partial));
143154
}
144155

145156
return TypeCombinator::union(...$strings);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BenchImplodeOptionalKeysBlowup;
4+
5+
/**
6+
* Regression test for implode() calling getAllArrays() on arrays with many optional keys.
7+
* getAllArrays() generates 2^N ConstantArrayType objects for N optional keys.
8+
* The fix processes keys incrementally, bailing early when partial count exceeds the limit.
9+
*
10+
* @param array{
11+
* a?: 'x', b?: 'x', c?: 'x', d?: 'x', e?: 'x',
12+
* f?: 'x', g?: 'x', h?: 'x', i?: 'x', j?: 'x',
13+
* k?: 'x', l?: 'x', m?: 'x', n?: 'x', o?: 'x',
14+
* p?: 'x', q?: 'x', r?: 'x', s?: 'x', t?: 'x'
15+
* } $data
16+
*/
17+
function joinOptional(array $data): string
18+
{
19+
return implode(',', $data);
20+
}

0 commit comments

Comments
 (0)