Skip to content

Commit cd93236

Browse files
committed
Honor @pure-unless-parameter-passed on constructor calls, unpacked and named arguments
NewHandler now applies the parameter-passed verdict to 'new' the same way the sibling callable verdict is applied (constructors return void, so createFromVariant cannot be reused). The verdict also treats argument unpacking conservatively (an unpacked argument might cover the flagged by-ref parameter, so the call stays possibly impure) and respects the flag's own TrinaryLogic certainty from union-variant composition (an uncertain flag downgrades a passed argument to Maybe instead of No).
1 parent 24ea51a commit cd93236

4 files changed

Lines changed: 154 additions & 2 deletions

File tree

src/Analyser/ExprHandler/NewHandler.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@ private function processConstructorReflection(string $className, New_ $expr, Mut
266266
if ($verdict !== null && $verdict->no()) {
267267
$certain = true;
268268
}
269+
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+
269280
$impurePoints[] = new ImpurePoint(
270281
$scope,
271282
$expr,

src/Reflection/Callables/SimpleImpurePoint.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,14 @@ public static function resolvePureUnlessParameterPassedVerdict(ParametersAccepto
237237
$verdict ??= TrinaryLogic::createYes();
238238

239239
$matchedArg = null;
240+
$hasUnpackedArg = false;
240241
$hasNamedParameter = false;
241242
foreach ($args as $i => $arg) {
243+
if ($arg->unpack) {
244+
$hasUnpackedArg = true;
245+
continue;
246+
}
247+
242248
if ($arg->name !== null) {
243249
$hasNamedParameter = true;
244250
if ($arg->name->name === $parameter->getName()) {
@@ -256,10 +262,24 @@ public static function resolvePureUnlessParameterPassedVerdict(ParametersAccepto
256262
}
257263

258264
if ($matchedArg === null) {
265+
if ($hasUnpackedArg) {
266+
// An unpacked argument list (...$args) might supply the flagged
267+
// by-ref parameter, so we cannot be sure the call stays pure.
268+
$verdict = $verdict->and(TrinaryLogic::createMaybe());
269+
}
270+
271+
continue;
272+
}
273+
274+
if ($parameter->isPureUnlessParameterPassedParameter()->yes()) {
275+
$verdict = $verdict->and(TrinaryLogic::createNo());
259276
continue;
260277
}
261278

262-
$verdict = $verdict->and(TrinaryLogic::createNo());
279+
// The flag itself is uncertain (e.g. only one variant of a union type
280+
// declares @pure-unless-parameter-passed), so passing an argument here
281+
// only makes the call possibly impure, not certainly impure.
282+
$verdict = $verdict->and(TrinaryLogic::createMaybe());
263283
}
264284

265285
return $verdict;

tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,22 @@ public function testPureUnlessParameterPassed(): void
389389
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().',
390390
62,
391391
],
392+
[
393+
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureUnpackingArgs().',
394+
72,
395+
],
396+
[
397+
'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().',
398+
91,
399+
],
400+
[
401+
'Impure instantiation of class PureUnlessParameterPassedFunction\MyReplacerConstructor in pure function PureUnlessParameterPassedFunction\pureConstructorPassingByRef().',
402+
127,
403+
],
404+
[
405+
'Possibly impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedA::m() in pure function PureUnlessParameterPassedFunction\pureUnionMethodPassingCount().',
406+
167,
407+
],
392408
]);
393409
}
394410

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?php declare(strict_types = 1);
1+
<?php // lint >= 8.0
22

33
namespace PureUnlessParameterPassedFunction;
44

@@ -61,3 +61,108 @@ function purePassingByRefAlias(string $s): string
6161

6262
return myReplacePhpstanAlias($s, $count);
6363
}
64+
65+
/**
66+
* @phpstan-pure
67+
*/
68+
function pureUnpackingArgs(string $s): string
69+
{
70+
$args = [$s];
71+
// An unpacked argument list might supply $count, so this stays possibly impure.
72+
return myReplace(...$args);
73+
}
74+
75+
/**
76+
* @phpstan-pure
77+
*/
78+
function pureNamedArgForOtherParameter(string $s): string
79+
{
80+
// The named argument targets $subject, not the flagged $count, so this stays pure.
81+
return myReplace(subject: $s);
82+
}
83+
84+
/**
85+
* @phpstan-pure
86+
*/
87+
function pureNamedArgForFlaggedParameter(string $s): string
88+
{
89+
$count = 0;
90+
// The named argument explicitly targets the flagged $count parameter.
91+
return myReplace(subject: $s, count: $count);
92+
}
93+
94+
class MyReplacerConstructor
95+
{
96+
97+
public string $s;
98+
99+
/**
100+
* @param-out int $count
101+
* @pure-unless-parameter-passed $count
102+
*/
103+
public function __construct(string $s, int &$count = 0)
104+
{
105+
$this->s = $s;
106+
$count = 1;
107+
}
108+
109+
}
110+
111+
/**
112+
* @phpstan-pure
113+
*/
114+
function pureConstructorNotPassingByRef(string $s): MyReplacerConstructor
115+
{
116+
// $count is omitted, so instantiation stays pure.
117+
return new MyReplacerConstructor($s);
118+
}
119+
120+
/**
121+
* @phpstan-pure
122+
*/
123+
function pureConstructorPassingByRef(string $s): MyReplacerConstructor
124+
{
125+
$count = 0;
126+
// $count is passed, so instantiation is impure (the flag is certain).
127+
return new MyReplacerConstructor($s, $count);
128+
}
129+
130+
interface PureUnlessParameterPassedA
131+
{
132+
133+
/**
134+
* @param-out int $count
135+
* @pure-unless-parameter-passed $count
136+
*/
137+
public function m(string $s, int &$count = 0): string;
138+
139+
}
140+
141+
interface PureUnlessParameterPassedB
142+
{
143+
144+
public function m(string $s, int &$count = 0): string;
145+
146+
}
147+
148+
/**
149+
* @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj
150+
* @phpstan-pure
151+
*/
152+
function pureUnionMethodOmittingCount($obj, string $s): string
153+
{
154+
// The flag is Yes in A and absent (No) in B, so combineAcceptors() merges it to Maybe.
155+
// $count is omitted here, so the call stays pure regardless of the flag's certainty.
156+
return $obj->m($s);
157+
}
158+
159+
/**
160+
* @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj
161+
* @phpstan-pure
162+
*/
163+
function pureUnionMethodPassingCount($obj, string $s): string
164+
{
165+
$count = 0;
166+
// $count is passed against the Maybe-flagged parameter, so this is possibly impure.
167+
return $obj->m($s, $count);
168+
}

0 commit comments

Comments
 (0)