Skip to content

Commit bc9200f

Browse files
phpstan-botclaude
andcommitted
Implement @phpstan-pure-unless-callable-is-impure tag and mark callback-accepting array functions with it
Instead of marking array_filter(), array_map(), array_reduce() and other callback-accepting array functions as unconditionally pure (hasSideEffects => false), implement the @phpstan-pure-unless-callable-is-impure PHPDoc tag to properly express that these functions are pure only when their callable argument is also pure. The phpdoc-parser already supports parsing the tag. This commit wires up the resolution in PHPStan core: - PhpDocNodeResolver::resolvePureUnlessCallableIsImpure() resolves the tag - ResolvedPhpDocBlock stores, merges, and exposes the parameter names - ResolvedPhpDocBlock::isPure() returns true when the tag is present - Function metadata type extended with optional pureUnlessCallableIsImpure key - All callback-accepting array functions in metadata now carry the flag The behavior is correct because PHPStan already propagates impure points from callback arguments through the processArgs mechanism: if the callback is impure, its impure points are merged into the overall expression, preventing the NoopExpressionNode from being emitted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a09ab3a commit bc9200f

12 files changed

Lines changed: 159 additions & 47 deletions

bin/functionMetadata_original.php

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
'apcu_key_info' => ['hasSideEffects' => true],
2121
'apcu_sma_info' => ['hasSideEffects' => true],
2222
'apcu_store' => ['hasSideEffects' => true],
23-
'array_all' => ['hasSideEffects' => false],
24-
'array_any' => ['hasSideEffects' => false],
23+
'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
24+
'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
2525
'array_change_key_case' => ['hasSideEffects' => false],
2626
'array_chunk' => ['hasSideEffects' => false],
2727
'array_column' => ['hasSideEffects' => false],
@@ -30,44 +30,44 @@
3030
'array_diff' => ['hasSideEffects' => false],
3131
'array_diff_assoc' => ['hasSideEffects' => false],
3232
'array_diff_key' => ['hasSideEffects' => false],
33-
'array_diff_uassoc' => ['hasSideEffects' => false],
34-
'array_diff_ukey' => ['hasSideEffects' => false],
33+
'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
34+
'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
3535
'array_fill' => ['hasSideEffects' => false],
3636
'array_fill_keys' => ['hasSideEffects' => false],
37-
'array_filter' => ['hasSideEffects' => false],
38-
'array_find' => ['hasSideEffects' => false],
39-
'array_find_key' => ['hasSideEffects' => false],
37+
'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
38+
'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
39+
'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
4040
'array_flip' => ['hasSideEffects' => false],
4141
'array_intersect' => ['hasSideEffects' => false],
4242
'array_intersect_assoc' => ['hasSideEffects' => false],
4343
'array_intersect_key' => ['hasSideEffects' => false],
44-
'array_intersect_uassoc' => ['hasSideEffects' => false],
45-
'array_intersect_ukey' => ['hasSideEffects' => false],
44+
'array_intersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
45+
'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
4646
'array_key_first' => ['hasSideEffects' => false],
4747
'array_key_last' => ['hasSideEffects' => false],
4848
'array_key_exists' => ['hasSideEffects' => false],
4949
'array_keys' => ['hasSideEffects' => false],
50-
'array_map' => ['hasSideEffects' => false],
50+
'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
5151
'array_merge' => ['hasSideEffects' => false],
5252
'array_merge_recursive' => ['hasSideEffects' => false],
5353
'array_pad' => ['hasSideEffects' => false],
5454
'array_pop' => ['hasSideEffects' => true],
5555
'array_product' => ['hasSideEffects' => false],
5656
'array_push' => ['hasSideEffects' => true],
5757
'array_rand' => ['hasSideEffects' => false],
58-
'array_reduce' => ['hasSideEffects' => false],
58+
'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
5959
'array_replace' => ['hasSideEffects' => false],
6060
'array_replace_recursive' => ['hasSideEffects' => false],
6161
'array_reverse' => ['hasSideEffects' => false],
6262
'array_shift' => ['hasSideEffects' => true],
6363
'array_slice' => ['hasSideEffects' => false],
6464
'array_sum' => ['hasSideEffects' => false],
65-
'array_udiff' => ['hasSideEffects' => false],
66-
'array_udiff_assoc' => ['hasSideEffects' => false],
67-
'array_udiff_uassoc' => ['hasSideEffects' => false],
68-
'array_uintersect' => ['hasSideEffects' => false],
69-
'array_uintersect_assoc' => ['hasSideEffects' => false],
70-
'array_uintersect_uassoc' => ['hasSideEffects' => false],
65+
'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
66+
'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
67+
'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
68+
'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
69+
'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
70+
'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
7171
'array_unique' => ['hasSideEffects' => false],
7272
'array_unshift' => ['hasSideEffects' => true],
7373
'array_values' => ['hasSideEffects' => false],

bin/generate-function-metadata.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ public function enterNode(Node $node)
119119
);
120120
}
121121

122-
/** @var array<string, array{hasSideEffects: bool}> $metadata */
122+
/** @var array<string, array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}> $metadata */
123123
$metadata = require __DIR__ . '/functionMetadata_original.php';
124124
foreach ($visitor->functions as $functionName) {
125125
if (array_key_exists($functionName, $metadata)) {
@@ -185,11 +185,14 @@ public function enterNode(Node $node)
185185
php;
186186
$content = '';
187187
foreach ($metadata as $name => $meta) {
188+
$pairs = sprintf('%s => %s', var_export('hasSideEffects', true), var_export($meta['hasSideEffects'], true));
189+
if (isset($meta['pureUnlessCallableIsImpure']) && $meta['pureUnlessCallableIsImpure']) {
190+
$pairs .= sprintf(', %s => %s', var_export('pureUnlessCallableIsImpure', true), var_export(true, true));
191+
}
188192
$content .= sprintf(
189-
"\t%s => [%s => %s],\n",
193+
"\t%s => [%s],\n",
190194
var_export($name, true),
191-
var_export('hasSideEffects', true),
192-
var_export($meta['hasSideEffects'], true),
195+
$pairs,
193196
);
194197
}
195198

resources/functionMetadata.php

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,8 @@
725725
'apcu_key_info' => ['hasSideEffects' => true],
726726
'apcu_sma_info' => ['hasSideEffects' => true],
727727
'apcu_store' => ['hasSideEffects' => true],
728-
'array_all' => ['hasSideEffects' => false],
729-
'array_any' => ['hasSideEffects' => false],
728+
'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
729+
'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
730730
'array_change_key_case' => ['hasSideEffects' => false],
731731
'array_chunk' => ['hasSideEffects' => false],
732732
'array_column' => ['hasSideEffects' => false],
@@ -735,48 +735,48 @@
735735
'array_diff' => ['hasSideEffects' => false],
736736
'array_diff_assoc' => ['hasSideEffects' => false],
737737
'array_diff_key' => ['hasSideEffects' => false],
738-
'array_diff_uassoc' => ['hasSideEffects' => false],
739-
'array_diff_ukey' => ['hasSideEffects' => false],
738+
'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
739+
'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
740740
'array_fill' => ['hasSideEffects' => false],
741741
'array_fill_keys' => ['hasSideEffects' => false],
742-
'array_filter' => ['hasSideEffects' => false],
743-
'array_find' => ['hasSideEffects' => false],
744-
'array_find_key' => ['hasSideEffects' => false],
742+
'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
743+
'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
744+
'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
745745
'array_first' => ['hasSideEffects' => false],
746746
'array_flip' => ['hasSideEffects' => false],
747747
'array_intersect' => ['hasSideEffects' => false],
748748
'array_intersect_assoc' => ['hasSideEffects' => false],
749749
'array_intersect_key' => ['hasSideEffects' => false],
750-
'array_intersect_uassoc' => ['hasSideEffects' => false],
751-
'array_intersect_ukey' => ['hasSideEffects' => false],
750+
'array_intersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
751+
'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
752752
'array_is_list' => ['hasSideEffects' => false],
753753
'array_key_exists' => ['hasSideEffects' => false],
754754
'array_key_first' => ['hasSideEffects' => false],
755755
'array_key_last' => ['hasSideEffects' => false],
756756
'array_keys' => ['hasSideEffects' => false],
757757
'array_last' => ['hasSideEffects' => false],
758-
'array_map' => ['hasSideEffects' => false],
758+
'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
759759
'array_merge' => ['hasSideEffects' => false],
760760
'array_merge_recursive' => ['hasSideEffects' => false],
761761
'array_pad' => ['hasSideEffects' => false],
762762
'array_pop' => ['hasSideEffects' => true],
763763
'array_product' => ['hasSideEffects' => false],
764764
'array_push' => ['hasSideEffects' => true],
765765
'array_rand' => ['hasSideEffects' => false],
766-
'array_reduce' => ['hasSideEffects' => false],
766+
'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
767767
'array_replace' => ['hasSideEffects' => false],
768768
'array_replace_recursive' => ['hasSideEffects' => false],
769769
'array_reverse' => ['hasSideEffects' => false],
770770
'array_search' => ['hasSideEffects' => false],
771771
'array_shift' => ['hasSideEffects' => true],
772772
'array_slice' => ['hasSideEffects' => false],
773773
'array_sum' => ['hasSideEffects' => false],
774-
'array_udiff' => ['hasSideEffects' => false],
775-
'array_udiff_assoc' => ['hasSideEffects' => false],
776-
'array_udiff_uassoc' => ['hasSideEffects' => false],
777-
'array_uintersect' => ['hasSideEffects' => false],
778-
'array_uintersect_assoc' => ['hasSideEffects' => false],
779-
'array_uintersect_uassoc' => ['hasSideEffects' => false],
774+
'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
775+
'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
776+
'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
777+
'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
778+
'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
779+
'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
780780
'array_unique' => ['hasSideEffects' => false],
781781
'array_unshift' => ['hasSideEffects' => true],
782782
'array_values' => ['hasSideEffects' => false],

src/PhpDoc/PhpDocNodeResolver.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,22 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool
673673
return false;
674674
}
675675

676+
/**
677+
* @return array<string, true>
678+
*/
679+
public function resolvePureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array
680+
{
681+
$parameters = [];
682+
foreach (['@pure-unless-callable-is-impure', '@phpstan-pure-unless-callable-is-impure'] as $tagName) {
683+
foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues($tagName) as $tagValue) {
684+
$parameterName = substr($tagValue->parameterName, 1);
685+
$parameters[$parameterName] = true;
686+
}
687+
}
688+
689+
return $parameters;
690+
}
691+
676692
public function resolveAllMethodsPure(PhpDocNode $phpDocNode): bool
677693
{
678694
return count($phpDocNode->getTagsByName('@phpstan-all-methods-pure')) > 0;

src/PhpDoc/ResolvedPhpDocBlock.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ final class ResolvedPhpDocBlock
139139
/** @var bool|'notLoaded'|null */
140140
private bool|string|null $isPure = 'notLoaded';
141141

142+
/** @var array<string, true>|false */
143+
private array|false $pureUnlessCallableIsImpureParameterNames = false;
144+
142145
private ?bool $areAllMethodsPure = null;
143146

144147
private ?bool $areAllMethodsImpure = null;
@@ -237,6 +240,7 @@ public static function createEmpty(): self
237240
$self->isInternal = false;
238241
$self->isFinal = false;
239242
$self->isPure = null;
243+
$self->pureUnlessCallableIsImpureParameterNames = [];
240244
$self->areAllMethodsPure = false;
241245
$self->areAllMethodsImpure = false;
242246
$self->isReadOnly = false;
@@ -293,6 +297,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi
293297
$result->isInternal = $this->isInternal();
294298
$result->isFinal = $this->isFinal();
295299
$result->isPure = self::mergePureTags($this->isPure(), $parent);
300+
$result->pureUnlessCallableIsImpureParameterNames = self::mergePureUnlessCallableIsImpureParameterNames($this->getPureUnlessCallableIsImpureParameterNames(), $parent, $parameterMapping);
296301
$result->areAllMethodsPure = $this->areAllMethodsPure();
297302
$result->areAllMethodsImpure = $this->areAllMethodsImpure();
298303
$result->isReadOnly = $this->isReadOnly();
@@ -352,6 +357,15 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self
352357
$newParamsImmediatelyInvokedCallable[$parameterNameMapping[$key]] = $immediatelyInvokedCallable;
353358
}
354359

360+
$newPureUnlessCallableIsImpureParameterNames = [];
361+
foreach ($this->getPureUnlessCallableIsImpureParameterNames() as $key => $value) {
362+
if (!array_key_exists($key, $parameterNameMapping)) {
363+
continue;
364+
}
365+
366+
$newPureUnlessCallableIsImpureParameterNames[$parameterNameMapping[$key]] = $value;
367+
}
368+
355369
$paramClosureThisTags = $this->getParamClosureThisTags();
356370
$newParamClosureThisTags = [];
357371
foreach ($paramClosureThisTags as $key => $paramClosureThisTag) {
@@ -399,6 +413,7 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self
399413
$self->paramTags = $newParamTags;
400414
$self->paramOutTags = $newParamOutTags;
401415
$self->paramsImmediatelyInvokedCallable = $newParamsImmediatelyInvokedCallable;
416+
$self->pureUnlessCallableIsImpureParameterNames = $newPureUnlessCallableIsImpureParameterNames;
402417
$self->paramClosureThisTags = $newParamClosureThisTags;
403418
$self->returnTag = $returnTag;
404419
$self->throwsTag = $this->throwsTag;
@@ -584,6 +599,18 @@ public function getParamsImmediatelyInvokedCallable(): array
584599
return $this->paramsImmediatelyInvokedCallable;
585600
}
586601

602+
/**
603+
* @return array<string, true>
604+
*/
605+
public function getPureUnlessCallableIsImpureParameterNames(): array
606+
{
607+
if ($this->pureUnlessCallableIsImpureParameterNames === false) {
608+
$this->pureUnlessCallableIsImpureParameterNames = $this->phpDocNodeResolver->resolvePureUnlessCallableIsImpure($this->phpDocNode);
609+
}
610+
611+
return $this->pureUnlessCallableIsImpureParameterNames;
612+
}
613+
587614
/**
588615
* @return array<string, ParamClosureThisTag>
589616
*/
@@ -818,6 +845,11 @@ public function isPure(): ?bool
818845
return $this->isPure;
819846
}
820847

848+
if (count($this->getPureUnlessCallableIsImpureParameterNames()) > 0) {
849+
$this->isPure = true;
850+
return $this->isPure;
851+
}
852+
821853
$this->isPure = null;
822854
}
823855

@@ -1085,6 +1117,25 @@ private static function mergeOneParentParamImmediatelyInvokedCallable(array $par
10851117
return $paramsImmediatelyInvokedCallable;
10861118
}
10871119

1120+
/**
1121+
* @param array<string, true> $parameterNames
1122+
* @return array<string, true>
1123+
*/
1124+
private static function mergePureUnlessCallableIsImpureParameterNames(array $parameterNames, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array
1125+
{
1126+
$parentParameterNames = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getPureUnlessCallableIsImpureParameterNames());
1127+
1128+
foreach ($parentParameterNames as $name => $value) {
1129+
if (array_key_exists($name, $parameterNames)) {
1130+
continue;
1131+
}
1132+
1133+
$parameterNames[$name] = $value;
1134+
}
1135+
1136+
return $parameterNames;
1137+
}
1138+
10881139
/**
10891140
* @param array<string, ParamClosureThisTag> $paramsClosureThisTags
10901141
* @return array<string, ParamClosureThisTag>

src/Reflection/SignatureMap/FunctionSignatureMapProvider.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ final class FunctionSignatureMapProvider implements SignatureMapProvider
2828
/** @var array<string, mixed[]> */
2929
private static array $signatureMaps = [];
3030

31-
/** @var array<string, array{hasSideEffects: bool}>|null */
31+
/** @var array<string, array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}>|null */
3232
private static ?array $functionMetadata = null;
3333

3434
public function __construct(
@@ -135,15 +135,15 @@ public function hasFunctionMetadata(string $name): bool
135135
}
136136

137137
/**
138-
* @return array{hasSideEffects: bool}
138+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
139139
*/
140140
public function getMethodMetadata(string $className, string $methodName): array
141141
{
142142
return $this->getFunctionMetadata(sprintf('%s::%s', $className, $methodName));
143143
}
144144

145145
/**
146-
* @return array{hasSideEffects: bool}
146+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
147147
*/
148148
public function getFunctionMetadata(string $functionName): array
149149
{
@@ -157,12 +157,12 @@ public function getFunctionMetadata(string $functionName): array
157157
}
158158

159159
/**
160-
* @return array<string, array{hasSideEffects: bool}>
160+
* @return array<string, array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}>
161161
*/
162162
private static function getFunctionMetadataMap(): array
163163
{
164164
if (self::$functionMetadata === null) {
165-
/** @var array<string, array{hasSideEffects: bool}> $metadata */
165+
/** @var array<string, array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}> $metadata */
166166
$metadata = require __DIR__ . '/../../../resources/functionMetadata.php';
167167
self::$functionMetadata = array_change_key_case($metadata, CASE_LOWER);
168168
}

src/Reflection/SignatureMap/Php8SignatureMapProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,15 +385,15 @@ public function hasFunctionMetadata(string $name): bool
385385
}
386386

387387
/**
388-
* @return array{hasSideEffects: bool}
388+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
389389
*/
390390
public function getMethodMetadata(string $className, string $methodName): array
391391
{
392392
return $this->functionSignatureMapProvider->getMethodMetadata($className, $methodName);
393393
}
394394

395395
/**
396-
* @return array{hasSideEffects: bool}
396+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
397397
*/
398398
public function getFunctionMetadata(string $functionName): array
399399
{

src/Reflection/SignatureMap/SignatureMapProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ public function hasMethodMetadata(string $className, string $methodName): bool;
2626
public function hasFunctionMetadata(string $name): bool;
2727

2828
/**
29-
* @return array{hasSideEffects: bool}
29+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
3030
*/
3131
public function getMethodMetadata(string $className, string $methodName): array;
3232

3333
/**
34-
* @return array{hasSideEffects: bool}
34+
* @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool}
3535
*/
3636
public function getFunctionMetadata(string $functionName): array;
3737

tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public function testSchema(): void
1818
$processor->process(Expect::arrayOf(
1919
Expect::structure([
2020
'hasSideEffects' => Expect::bool()->required(),
21+
'pureUnlessCallableIsImpure' => Expect::bool(),
2122
])->required(),
2223
)->required(), $data);
2324
}

0 commit comments

Comments
 (0)