Skip to content

Commit 33df0ad

Browse files
zonuexestaabmclaude
authored
Implement @pure-unless-parameter-passed (#6018)
Co-authored-by: Markus Staab <maggus.staab@googlemail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ac2a474 commit 33df0ad

93 files changed

Lines changed: 2827 additions & 987 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎bin/functionMetadata_original.php‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,23 @@
55
* keyed by lowercase function name or "Class::method". resources/functionMetadata.php
66
* is generated from this file by bin/generate-function-metadata.php.
77
*
8-
* Each entry is exactly one of these shapes:
8+
* Each entry has one of these shapes:
99
*
1010
* - ['hasSideEffects' => bool]
1111
* false: the call is pure. true: the call has side effects.
1212
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>]
1313
* the call is pure unless one of the listed callable parameters
1414
* (keyed by parameter name) receives an impure callable, e.g. array_map()
1515
* whose only side effects come from its 'callback' argument.
16+
* - ['pureUnlessParameterPassedParameters' => array<string, true>]
17+
* the call is pure unless one of the listed (by-ref out) optional parameters
18+
* (keyed by parameter name) receives an argument, e.g. str_replace()
19+
* whose only side effect is writing to its optional 'count' argument.
20+
*
21+
* The last two can be combined for a call that is pure unless either happens.
1622
*/
1723

18-
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
24+
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}> */
1925
return [
2026
'abs' => ['hasSideEffects' => false],
2127
'acos' => ['hasSideEffects' => false],
@@ -265,13 +271,18 @@
265271
'pclose' => ['hasSideEffects' => true],
266272
'popen' => ['hasSideEffects' => true],
267273
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
274+
'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]],
268275
'readfile' => ['hasSideEffects' => true],
269276
'rename' => ['hasSideEffects' => true],
270277
'rewind' => ['hasSideEffects' => true],
271278
'rmdir' => ['hasSideEffects' => true],
272279
'sprintf' => ['hasSideEffects' => false],
273280
'str_decrement' => ['hasSideEffects' => false],
274281
'str_increment' => ['hasSideEffects' => false],
282+
// 'count'/'replace_count': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls
283+
// back to the legacy functionMap.php name.
284+
'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
285+
'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
275286
'symlink' => ['hasSideEffects' => true],
276287
'time' => ['hasSideEffects' => true],
277288
'tempnam' => ['hasSideEffects' => true],

‎bin/generate-function-metadata.php‎

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,25 @@ public function enterNode(Node $node)
124124
);
125125
}
126126

127-
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>}> $metadata */
127+
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}> $metadata */
128128
$metadata = require __DIR__ . '/functionMetadata_original.php';
129129
foreach ($visitor->functions as $functionName) {
130130
if (array_key_exists($functionName, $metadata)) {
131131
if (isset($metadata[$functionName]['hasSideEffects']) && $metadata[$functionName]['hasSideEffects']) {
132132
throw new ShouldNotHappenException($functionName);
133133
}
134134

135-
if (isset($metadata[$functionName]['pureUnlessCallableIsImpureParameters'])) {
136-
$metadata[$functionName] = [
137-
'pureUnlessCallableIsImpureParameters' => $metadata[$functionName]['pureUnlessCallableIsImpureParameters'],
138-
];
135+
$conditions = [];
136+
foreach (['pureUnlessCallableIsImpureParameters', 'pureUnlessParameterPassedParameters'] as $conditionKey) {
137+
if (!isset($metadata[$functionName][$conditionKey])) {
138+
continue;
139+
}
140+
141+
$conditions[$conditionKey] = $metadata[$functionName][$conditionKey];
142+
}
143+
144+
if ($conditions !== []) {
145+
$metadata[$functionName] = $conditions;
139146

140147
continue;
141148
}
@@ -197,40 +204,49 @@ public function enterNode(Node $node)
197204
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>] - pure unless
198205
* one of the listed callable parameters (keyed by parameter name) receives an
199206
* impure callable, e.g. array_map()'s 'callback'.
207+
* - ['pureUnlessParameterPassedParameters' => array<string, true>] - pure unless
208+
* one of the listed (by-ref out) parameters (keyed by parameter name) receives
209+
* an argument, e.g. str_replace()'s 'count'.
200210
*/
201211
202-
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
212+
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}> */
203213
return [
204214
%s
205215
];
206216
php;
207217
$content = '';
208218
$escape = static fn (mixed $value): string => var_export($value, true);
209-
$encodeHasSideEffects = static fn (array $meta) => [$escape('hasSideEffects'), $escape($meta['hasSideEffects'])];
210-
$encodePureUnlessCallableIsImpureParameters = static fn (array $meta) => [
211-
$escape('pureUnlessCallableIsImpureParameters'),
212-
sprintf(
213-
'[%s]',
214-
implode(
215-
' ,',
216-
array_map(
217-
static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)),
218-
array_keys($meta['pureUnlessCallableIsImpureParameters']),
219-
$meta['pureUnlessCallableIsImpureParameters'],
220-
),
219+
$encodeParameterMap = static fn (array $parameters) => sprintf(
220+
'[%s]',
221+
implode(
222+
', ',
223+
array_map(
224+
static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)),
225+
array_keys($parameters),
226+
$parameters,
221227
),
222228
),
223-
];
229+
);
224230

225231
foreach ($metadata as $name => $meta) {
232+
$entries = [];
233+
if (isset($meta['hasSideEffects'])) {
234+
$entries[] = sprintf('%s => %s', $escape('hasSideEffects'), $escape($meta['hasSideEffects']));
235+
}
236+
if (isset($meta['pureUnlessCallableIsImpureParameters'])) {
237+
$entries[] = sprintf('%s => %s', $escape('pureUnlessCallableIsImpureParameters'), $encodeParameterMap($meta['pureUnlessCallableIsImpureParameters']));
238+
}
239+
if (isset($meta['pureUnlessParameterPassedParameters'])) {
240+
$entries[] = sprintf('%s => %s', $escape('pureUnlessParameterPassedParameters'), $encodeParameterMap($meta['pureUnlessParameterPassedParameters']));
241+
}
242+
if ($entries === []) {
243+
throw new ShouldNotHappenException($escape($meta));
244+
}
245+
226246
$content .= sprintf(
227-
"\t%s => [%s => %s],\n",
247+
"\t%s => [%s],\n",
228248
var_export($name, true),
229-
...match (true) {
230-
isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta),
231-
isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta),
232-
default => throw new ShouldNotHappenException($escape($meta)),
233-
},
249+
implode(', ', $entries),
234250
);
235251
}
236252

‎resources/functionMetadata.php‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>] - pure unless
2020
* one of the listed callable parameters (keyed by parameter name) receives an
2121
* impure callable, e.g. array_map()'s 'callback'.
22+
* - ['pureUnlessParameterPassedParameters' => array<string, true>] - pure unless
23+
* one of the listed (by-ref out) parameters (keyed by parameter name) receives
24+
* an argument, e.g. str_replace()'s 'count'.
2225
*/
2326

24-
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
27+
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}> */
2528
return [
2629
'BackedEnum::from' => ['hasSideEffects' => false],
2730
'BackedEnum::tryFrom' => ['hasSideEffects' => false],
@@ -777,10 +780,10 @@
777780
'array_sum' => ['hasSideEffects' => false],
778781
'array_udiff' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true]],
779782
'array_udiff_assoc' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]],
780-
'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true ,'key_comp_func' => true]],
783+
'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true, 'key_comp_func' => true]],
781784
'array_uintersect' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]],
782785
'array_uintersect_assoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]],
783-
'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true ,'key_compare_func' => true]],
786+
'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true, 'key_compare_func' => true]],
784787
'array_unique' => ['hasSideEffects' => false],
785788
'array_unshift' => ['hasSideEffects' => true],
786789
'array_values' => ['hasSideEffects' => false],
@@ -1665,6 +1668,7 @@
16651668
'rtrim' => ['hasSideEffects' => false],
16661669
'sha1' => ['hasSideEffects' => false],
16671670
'sha1_file' => ['hasSideEffects' => true],
1671+
'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]],
16681672
'sin' => ['hasSideEffects' => false],
16691673
'sinh' => ['hasSideEffects' => false],
16701674
'sizeof' => ['hasSideEffects' => false],
@@ -1679,8 +1683,10 @@
16791683
'str_ends_with' => ['hasSideEffects' => false],
16801684
'str_getcsv' => ['hasSideEffects' => false],
16811685
'str_increment' => ['hasSideEffects' => false],
1686+
'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
16821687
'str_pad' => ['hasSideEffects' => false],
16831688
'str_repeat' => ['hasSideEffects' => false],
1689+
'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
16841690
'str_rot13' => ['hasSideEffects' => false],
16851691
'str_split' => ['hasSideEffects' => false],
16861692
'str_starts_with' => ['hasSideEffects' => false],

‎src/Analyser/ExprHandler/FuncCallHandler.php‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
199199
$callableThrowPoints = array_values(array_filter($callableThrowPoints, static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit()));
200200
}
201201
$throwPoints = array_merge($throwPoints, $callableThrowPoints);
202-
$impurePoints = array_merge($impurePoints, array_map(static fn (SimpleImpurePoint $impurePoint) => new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $parametersAcceptor->getImpurePoints()));
202+
// A callable value's impure points are resolved from its
203+
// ParametersAcceptor alone, so a conditionally pure function reached
204+
// through a first-class callable arrives here as an unconditional
205+
// "possibly impure" point. The arguments are known at this call site,
206+
// so settle the verdict here.
207+
$callableImpurePoints = SimpleImpurePoint::narrowByConditionalPurity($parametersAcceptor->getImpurePoints(), $parametersAcceptor, $scope, $expr->getArgs());
208+
$impurePoints = array_merge($impurePoints, array_map(static fn (SimpleImpurePoint $impurePoint) => new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $callableImpurePoints));
203209

204210
$scope = $this->closureProcessor->processImmediatelyCalledCallable($scope, $parametersAcceptor->getInvalidateExpressions(), $parametersAcceptor->getUsedVariables());
205211
}

‎src/Analyser/ExprHandler/NewHandler.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,15 @@ private function getConstructorImpurePoints(?ExtendedMethodReflection $construct
381381
}
382382

383383
$certain = $constructorReflection->isPure()->no();
384+
// A constructor can carry both flags at once, so combine the verdicts
385+
// the same way SimpleImpurePoint::createFromVariant() does for calls:
386+
// Yes = pure, No = impure, Maybe = possibly impure.
384387
$verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs());
388+
$passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs());
389+
if ($passedVerdict !== null) {
390+
$verdict = $verdict === null ? $passedVerdict : $verdict->and($passedVerdict);
391+
}
392+
385393
if ($verdict !== null && $verdict->yes()) {
386394
return [];
387395
}

‎src/Analyser/MutatingScope.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2039,6 +2039,7 @@ public function enterTrait(ClassReflection $traitReflection): self
20392039
* @param array<string, bool> $immediatelyInvokedCallableParameters
20402040
* @param array<string, Type> $phpDocClosureThisTypeParameters
20412041
* @param array<string, bool> $phpDocPureUnlessCallableIsImpureParameters
2042+
* @param array<string, bool> $phpDocPureUnlessParameterPassedParameters
20422043
*/
20432044
public function enterClassMethod(
20442045
Node\Stmt\ClassMethod $classMethod,
@@ -2061,6 +2062,7 @@ public function enterClassMethod(
20612062
bool $isConstructor = false,
20622063
?ResolvedPhpDocBlock $resolvedPhpDocBlock = null,
20632064
array $phpDocPureUnlessCallableIsImpureParameters = [],
2065+
array $phpDocPureUnlessParameterPassedParameters = [],
20642066
): self
20652067
{
20662068
if (!$this->isInClass()) {
@@ -2097,6 +2099,7 @@ public function enterClassMethod(
20972099
$isConstructor,
20982100
$this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)),
20992101
$phpDocPureUnlessCallableIsImpureParameters,
2102+
$phpDocPureUnlessParameterPassedParameters,
21002103
),
21012104
!$classMethod->isStatic(),
21022105
);
@@ -2188,6 +2191,7 @@ public function enterPropertyHook(
21882191
false,
21892192
$this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)),
21902193
[],
2194+
[],
21912195
),
21922196
true,
21932197
);
@@ -2265,6 +2269,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func
22652269
* @param array<string, bool> $immediatelyInvokedCallableParameters
22662270
* @param array<string, Type> $phpDocClosureThisTypeParameters
22672271
* @param array<string, bool> $pureUnlessCallableIsImpureParameters
2272+
* @param array<string, bool> $pureUnlessParameterPassedParameters
22682273
*/
22692274
public function enterFunction(
22702275
Node\Stmt\Function_ $function,
@@ -2283,6 +2288,7 @@ public function enterFunction(
22832288
array $immediatelyInvokedCallableParameters = [],
22842289
array $phpDocClosureThisTypeParameters = [],
22852290
array $pureUnlessCallableIsImpureParameters = [],
2291+
array $pureUnlessParameterPassedParameters = [],
22862292
): self
22872293
{
22882294
return $this->enterFunctionLike(
@@ -2309,6 +2315,7 @@ public function enterFunction(
23092315
$phpDocClosureThisTypeParameters,
23102316
$this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)),
23112317
$pureUnlessCallableIsImpureParameters,
2318+
$pureUnlessParameterPassedParameters,
23122319
),
23132320
false,
23142321
);

‎src/Analyser/PhpDocsResolver.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function __construct(
4242
}
4343

4444
/**
45-
* @return array{TemplateTypeMap, array<string, Type>, array<string, bool>, array<string, Type>, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array<string, Type>, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array<string, bool>}
45+
* @return array{TemplateTypeMap, array<string, Type>, array<string, bool>, array<string, Type>, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array<string, Type>, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array<string, bool>, array<string, bool>}
4646
*/
4747
public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array
4848
{
@@ -73,6 +73,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
7373
$functionName = null;
7474
$phpDocParameterOutTypes = [];
7575
$phpDocPureUnlessCallableIsImpureParameters = [];
76+
$phpDocPureUnlessParameterPassedParameters = [];
7677

7778
if ($node instanceof Node\Stmt\ClassMethod) {
7879
if (!$scope->isInClass()) {
@@ -207,6 +208,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
207208
$selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null;
208209
$varTags = $resolvedPhpDoc->getVarTags();
209210
$phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure();
211+
$phpDocPureUnlessParameterPassedParameters = $resolvedPhpDoc->getParamsPureUnlessParameterPassed();
210212
}
211213

212214
if ($acceptsNamedArguments && $scope->isInClass()) {
@@ -234,7 +236,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
234236
}
235237
}
236238

237-
return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters];
239+
return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters];
238240
}
239241

240242
private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $nativeReturnType): ?Type

‎src/Analyser/StmtHandler/ClassMethodHandler.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public function processStmt(
7676
{
7777
$dependencies = [];
7878
$dependencies[] = $this->attributesHandler->processAttributeGroups($nodeScopeResolver, $stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback);
79-
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->phpDocsResolver->getPhpDocs($scope, $stmt);
79+
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters] = $this->phpDocsResolver->getPhpDocs($scope, $stmt);
8080

8181
$dependencies[] = $this->parametersProcessor->processParams($nodeScopeResolver, $stmt, $stmt->params, $scope, $storage, $nodeCallback);
8282

@@ -112,6 +112,7 @@ public function processStmt(
112112
$isConstructor,
113113
null,
114114
$pureUnlessCallableIsImpureParameters,
115+
$pureUnlessParameterPassedParameters,
115116
);
116117

117118
if (!$scope->isInClass()) {

0 commit comments

Comments
 (0)