Skip to content

Commit a4f91d3

Browse files
committed
Report certain impurity when a @pure-unless-parameter-passed argument is passed
createFromVariant suppressed the impure point when the flagged parameter was omitted, but never promoted the verdict to certain when it was passed, unlike its @pure-unless-callable-is-impure sibling right above it and unlike NewHandler's own constructor handling. Passing the by-ref out-parameter is a definite side effect, not a possible one. Also covers intersection types, method inheritance (incl. a renamed parameter), and first-class callables.
1 parent 88b07e2 commit a4f91d3

4 files changed

Lines changed: 219 additions & 13 deletions

File tree

src/Reflection/Callables/SimpleImpurePoint.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,15 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec
7676

7777
if (!$certain && $scope !== null && $variant !== null) {
7878
$passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args);
79-
if ($passedVerdict !== null && $passedVerdict->yes()) {
80-
// None of the @pure-unless-parameter-passed by-ref parameters
81-
// received an argument, so the call is pure.
82-
return null;
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+
}
8388
}
8489
}
8590

tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,23 +327,24 @@ public function testPureUnlessCallableIsImpurePhp84(): void
327327
]);
328328
}
329329

330+
#[RequiresPhp('>= 8.1.0')]
330331
public function testPureUnlessParameterPassed(): void
331332
{
332333
$this->analyse([__DIR__ . '/data/pure-unless-parameter-passed.php'], [
333334
[
334-
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().',
335+
'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().',
335336
44,
336337
],
337338
[
338-
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().',
339+
'Impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().',
339340
62,
340341
],
341342
[
342343
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureUnpackingArgs().',
343344
72,
344345
],
345346
[
346-
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().',
347+
'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().',
347348
91,
348349
],
349350
[
@@ -354,18 +355,42 @@ public function testPureUnlessParameterPassed(): void
354355
'Possibly impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedA::m() in pure function PureUnlessParameterPassedFunction\pureUnionMethodPassingCount().',
355356
167,
356357
],
358+
[
359+
'Impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedIntersectionA::m() in pure function PureUnlessParameterPassedFunction\pureIntersectionMethodPassingCount().',
360+
206,
361+
],
362+
[
363+
'Impure call to method PureUnlessParameterPassedFunction\Replacer::replace() in pure function PureUnlessParameterPassedFunction\pureCallingMethodPassingByRef().',
364+
241,
365+
],
366+
[
367+
'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingInheritedMethodPassingByRef().',
368+
297,
369+
],
370+
[
371+
'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerRenamedChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingRenamedInheritedMethodPassingByRef().',
372+
318,
373+
],
374+
[
375+
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallableOmittingCount().',
376+
331,
377+
],
378+
[
379+
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallablePassingCount().',
380+
343,
381+
],
357382
]);
358383
}
359384

360385
public function testPureUnlessParameterPassedBuiltin(): void
361386
{
362387
$this->analyse([__DIR__ . '/data/pure-unless-parameter-passed-builtin.php'], [
363388
[
364-
'Possibly impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().',
389+
'Impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().',
365390
22,
366391
],
367392
[
368-
'Possibly impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().',
393+
'Impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().',
369394
40,
370395
],
371396
]);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function pureStrReplaceWithoutCount(string $s): string
1616
*/
1717
function pureStrReplaceWithCount(string $s): string
1818
{
19-
// The by-ref $count is passed, so str_replace() is possibly impure.
19+
// The by-ref $count is passed, so str_replace() is impure (the flag is certain).
2020
$count = 0;
2121

2222
return str_replace('a', 'b', $s, $count);
@@ -36,6 +36,6 @@ function purePregMatchWithoutMatches(string $s): int
3636
*/
3737
function purePregMatchWithMatches(string $s): int
3838
{
39-
// The by-ref $matches is passed, so preg_match() is possibly impure.
39+
// The by-ref $matches is passed, so preg_match() is impure (the flag is certain).
4040
return (int) preg_match('/a/', $s, $matches);
4141
}

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

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?php // lint >= 8.0
1+
<?php // lint >= 8.1
22

33
namespace PureUnlessParameterPassedFunction;
44

@@ -38,7 +38,7 @@ function pureNotPassingByRef(string $s): string
3838
*/
3939
function purePassingByRef(string $s): string
4040
{
41-
// $count is passed, so myReplace() is possibly impure.
41+
// $count is passed, so myReplace() is impure (the flag is certain).
4242
$count = 0;
4343

4444
return myReplace($s, $count);
@@ -166,3 +166,179 @@ function pureUnionMethodPassingCount($obj, string $s): string
166166
// $count is passed against the Maybe-flagged parameter, so this is possibly impure.
167167
return $obj->m($s, $count);
168168
}
169+
170+
interface PureUnlessParameterPassedIntersectionA
171+
{
172+
173+
/**
174+
* @param-out int $count
175+
* @pure-unless-parameter-passed $count
176+
*/
177+
public function m(string $s, int &$count = 0): string;
178+
179+
}
180+
181+
interface PureUnlessParameterPassedIntersectionB
182+
{
183+
184+
public function n(): string;
185+
186+
}
187+
188+
/**
189+
* @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj
190+
* @phpstan-pure
191+
*/
192+
function pureIntersectionMethodOmittingCount($obj, string $s): string
193+
{
194+
// $count is omitted here, so the call stays pure.
195+
return $obj->m($s);
196+
}
197+
198+
/**
199+
* @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj
200+
* @phpstan-pure
201+
*/
202+
function pureIntersectionMethodPassingCount($obj, string $s): string
203+
{
204+
$count = 0;
205+
// $count is passed, so this is impure (the flag is certain).
206+
return $obj->m($s, $count);
207+
}
208+
209+
class Replacer
210+
{
211+
212+
/**
213+
* @param-out int $count
214+
* @pure-unless-parameter-passed $count
215+
*/
216+
public function replace(string $subject, int &$count = 0): string
217+
{
218+
$count = 1;
219+
220+
return $subject;
221+
}
222+
223+
}
224+
225+
/**
226+
* @phpstan-pure
227+
*/
228+
function pureCallingMethodNotPassingByRef(Replacer $replacer, string $s): string
229+
{
230+
// $count is omitted, so the call stays pure.
231+
return $replacer->replace($s);
232+
}
233+
234+
/**
235+
* @phpstan-pure
236+
*/
237+
function pureCallingMethodPassingByRef(Replacer $replacer, string $s): string
238+
{
239+
$count = 0;
240+
// $count is passed, so the call is impure (the flag is certain).
241+
return $replacer->replace($s, $count);
242+
}
243+
244+
abstract class InheritedReplacerParent
245+
{
246+
247+
/**
248+
* @param-out int $count
249+
* @pure-unless-parameter-passed $count
250+
*/
251+
abstract public function replace(string $subject, int &$count = 0): string;
252+
253+
}
254+
255+
class InheritedReplacerChild extends InheritedReplacerParent
256+
{
257+
258+
public function replace(string $subject, int &$count = 0): string
259+
{
260+
$count = 1;
261+
262+
return $subject;
263+
}
264+
265+
}
266+
267+
class InheritedReplacerRenamedChild extends InheritedReplacerParent
268+
{
269+
270+
public function replace(string $subject, int &$cnt = 0): string
271+
{
272+
$cnt = 1;
273+
274+
return $subject;
275+
}
276+
277+
}
278+
279+
/**
280+
* @phpstan-pure
281+
*/
282+
function pureCallingInheritedMethodNotPassingByRef(InheritedReplacerChild $replacer, string $s): string
283+
{
284+
// The child does not re-declare @pure-unless-parameter-passed; it is inherited
285+
// from the parent. $count is omitted here, so the call stays pure.
286+
return $replacer->replace($s);
287+
}
288+
289+
/**
290+
* @phpstan-pure
291+
*/
292+
function pureCallingInheritedMethodPassingByRef(InheritedReplacerChild $replacer, string $s): string
293+
{
294+
$count = 0;
295+
// The inherited @pure-unless-parameter-passed applies to the child, so passing
296+
// $count makes the call impure (the flag is certain).
297+
return $replacer->replace($s, $count);
298+
}
299+
300+
/**
301+
* @phpstan-pure
302+
*/
303+
function pureCallingRenamedInheritedMethodNotPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string
304+
{
305+
// The parent flags $count; the child renames it to $cnt. The inherited flag
306+
// still applies to the renamed parameter. It is omitted here, so this stays pure.
307+
return $replacer->replace($s);
308+
}
309+
310+
/**
311+
* @phpstan-pure
312+
*/
313+
function pureCallingRenamedInheritedMethodPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string
314+
{
315+
$cnt = 0;
316+
// The inherited flag applies to the renamed parameter, so passing it makes
317+
// the call impure (the flag is certain).
318+
return $replacer->replace($s, $cnt);
319+
}
320+
321+
/**
322+
* @phpstan-pure
323+
*/
324+
function pureCallingFirstClassCallableOmittingCount(string $s): string
325+
{
326+
$f = myReplace(...);
327+
// A first-class callable's purity is evaluated from its ParametersAcceptor
328+
// alone, without scope/args, so @pure-unless-parameter-passed cannot gate on
329+
// whether $count is actually passed at this call site; it stays possibly
330+
// impure even though $count is omitted here.
331+
return $f($s);
332+
}
333+
334+
/**
335+
* @phpstan-pure
336+
*/
337+
function pureCallingFirstClassCallablePassingCount(string $s): string
338+
{
339+
$count = 0;
340+
$f = myReplace(...);
341+
// The first-class callable is called with the flagged $count passed, so this
342+
// stays possibly impure.
343+
return $f($s, $count);
344+
}

0 commit comments

Comments
 (0)