Skip to content

Commit 0b1a724

Browse files
zonuexestaabm
authored andcommitted
Combine the callable and parameter-passed purity verdicts
A function can carry both @pure-unless-callable-is-impure and @pure-unless-parameter-passed at once - preg_replace_callback() is pure unless its callback is impure or its $count is passed. The two verdicts were checked in sequence, so a pure callback short-circuited to "pure" and never looked at $count. Combine them with TrinaryLogic::and() in both SimpleImpurePoint::createFromVariant() and NewHandler, and flag preg_replace_callback()'s $count.
1 parent a0972d4 commit 0b1a724

6 files changed

Lines changed: 70 additions & 29 deletions

File tree

bin/functionMetadata_original.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
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.
@@ -17,9 +17,12 @@
1717
* the call is pure unless one of the listed (by-ref out) parameters
1818
* (keyed by parameter name) receives an argument, e.g. str_replace()
1919
* 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,
22+
* e.g. preg_replace_callback() (impure callback or a passed 'count').
2023
*/
2124

22-
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}> */
25+
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}|array{pureUnlessCallableIsImpureParameters: array<string, bool>, pureUnlessParameterPassedParameters: array<string, bool>}> */
2326
return [
2427
'abs' => ['hasSideEffects' => false],
2528
'acos' => ['hasSideEffects' => false],
@@ -274,7 +277,7 @@
274277
'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
275278
'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
276279
'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
277-
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
280+
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true], 'pureUnlessParameterPassedParameters' => ['count' => true]],
278281
'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]],
279282
'readfile' => ['hasSideEffects' => true],
280283
'rename' => ['hasSideEffects' => true],

resources/functionMetadata.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* an argument, e.g. str_replace()'s 'count'.
2525
*/
2626

27-
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}> */
27+
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}|array{pureUnlessCallableIsImpureParameters: array<string, bool>, pureUnlessParameterPassedParameters: array<string, bool>}> */
2828
return [
2929
'BackedEnum::from' => ['hasSideEffects' => false],
3030
'BackedEnum::tryFrom' => ['hasSideEffects' => false],
@@ -1642,7 +1642,7 @@
16421642
'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
16431643
'preg_quote' => ['hasSideEffects' => false],
16441644
'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
1645-
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
1645+
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true], 'pureUnlessParameterPassedParameters' => ['count' => true]],
16461646
'preg_split' => ['hasSideEffects' => false],
16471647
'property_exists' => ['hasSideEffects' => false],
16481648
'quoted_printable_decode' => ['hasSideEffects' => false],

src/Analyser/ExprHandler/NewHandler.php

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -259,24 +259,22 @@ private function processConstructorReflection(string $className, New_ $expr, Mut
259259
if ($constructorReflection !== null) {
260260
if (!$constructorReflection->hasSideEffects()->no()) {
261261
$certain = $constructorReflection->isPure()->no();
262+
// A constructor can carry both flags at once, so combine the verdicts
263+
// the same way SimpleImpurePoint::createFromVariant() does for calls:
264+
// Yes = pure, No = impure, Maybe = possibly impure.
262265
$verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs());
266+
$passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs());
267+
if ($passedVerdict !== null) {
268+
$verdict = $verdict === null ? $passedVerdict : $verdict->and($passedVerdict);
269+
}
270+
263271
if ($verdict !== null && $verdict->yes()) {
264272
return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints];
265273
}
266274
if ($verdict !== null && $verdict->no()) {
267275
$certain = true;
268276
}
269277

270-
if (!$certain) {
271-
$passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs());
272-
if ($passedVerdict !== null && $passedVerdict->yes()) {
273-
return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints];
274-
}
275-
if ($passedVerdict !== null && $passedVerdict->no()) {
276-
$certain = true;
277-
}
278-
}
279-
280278
$impurePoints[] = new ImpurePoint(
281279
$scope,
282280
$expr,

src/Reflection/Callables/SimpleImpurePoint.php

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,16 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec
6363
}
6464

6565
if (!$certain && $scope !== null && $variant !== null) {
66+
// A function can carry both flags at once (e.g. preg_replace_callback,
67+
// which is pure unless its callback is impure or its $count is passed).
68+
// It stays pure only when both verdicts agree it is pure, so combine
69+
// them: Yes = pure, No = impure, Maybe = possibly impure.
6670
$verdict = self::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, $args);
71+
$passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args);
72+
if ($passedVerdict !== null) {
73+
$verdict = $verdict === null ? $passedVerdict : $verdict->and($passedVerdict);
74+
}
75+
6776
if ($verdict !== null) {
6877
if ($verdict->yes()) {
6978
return null;
@@ -74,20 +83,6 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec
7483
}
7584
}
7685

77-
if (!$certain && $scope !== null && $variant !== null) {
78-
$passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args);
79-
if ($passedVerdict !== null) {
80-
if ($passedVerdict->yes()) {
81-
// None of the @pure-unless-parameter-passed by-ref parameters
82-
// received an argument, so the call is pure.
83-
return null;
84-
}
85-
if ($passedVerdict->no()) {
86-
$certain = true;
87-
}
88-
}
89-
}
90-
9186
if ($function instanceof FunctionReflection) {
9287
if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$function->getName()]) && $scope !== null) {
9388
[

tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ public function testPureUnlessParameterPassedBuiltin(): void
460460
'Impure call to function preg_filter() in pure function PureUnlessParameterPassedBuiltin\purePregFilterWithCount().',
461461
60,
462462
],
463+
[
464+
'Impure call to function preg_replace_callback() in pure function PureUnlessParameterPassedBuiltin\purePregReplaceCallbackWithCount().',
465+
80,
466+
],
467+
[
468+
'Impure call to function preg_replace_callback() in pure function PureUnlessParameterPassedBuiltin\purePregReplaceCallbackImpureCallback().',
469+
89,
470+
],
471+
[
472+
'Impure echo in pure function PureUnlessParameterPassedBuiltin\purePregReplaceCallbackImpureCallback().',
473+
90,
474+
],
463475
]);
464476
}
465477

tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,36 @@ function purePregFilterWithCount(string $s): ?string
5959

6060
return preg_filter('/a/', 'b', $s, -1, $count);
6161
}
62+
63+
/**
64+
* @phpstan-pure
65+
*/
66+
function purePregReplaceCallbackPureCallback(string $s): ?string
67+
{
68+
// preg_replace_callback() carries both flags: the callback is pure and the by-ref
69+
// $count is omitted, so the call stays pure.
70+
return preg_replace_callback('/a/', static fn (array $m): string => $m[0], $s);
71+
}
72+
73+
/**
74+
* @phpstan-pure
75+
*/
76+
function purePregReplaceCallbackWithCount(string $s): ?string
77+
{
78+
$count = 0;
79+
// The callback is pure but the by-ref $count is passed, so the call is impure.
80+
return preg_replace_callback('/a/', static fn (array $m): string => $m[0], $s, -1, $count);
81+
}
82+
83+
/**
84+
* @phpstan-pure
85+
*/
86+
function purePregReplaceCallbackImpureCallback(string $s): ?string
87+
{
88+
// The callback itself is impure, so the call is impure regardless of $count.
89+
return preg_replace_callback('/a/', static function (array $m): string {
90+
echo $m[0];
91+
92+
return $m[0];
93+
}, $s);
94+
}

0 commit comments

Comments
 (0)