Skip to content

Commit db42cf0

Browse files
committed
Fix nullsafe.neverNull false positive when the ?-> operand is nullable
$a?->b on the left of ??, or inside isset()/empty(), was reported as redundant whenever $a?->b as a whole was nullable, without checking $a itself. When $a is nullable the ?-> is what yields the null those constructs handle, so it's required, and switching to -> as advised would fatal at runtime. Report only when the operand is never null, and recurse into a nullsafe operand so an inner never-null link ($nonNull?->nullable?->x) is still caught, matching NullsafePropertyFetchRule outside these contexts. Adds the same treatPhpDocTypesAsCertain tip, and fixes the bug-7109 expectations that had baked in the false positive. Fixes phpstan/phpstan#14311
1 parent e80d7fe commit db42cf0

6 files changed

Lines changed: 95 additions & 47 deletions

File tree

src/Rules/IssetCheck.php

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public function __construct(
3131
private bool $checkAdvancedIsset,
3232
#[AutowiredParameter]
3333
private bool $treatPhpDocTypesAsCertain,
34+
#[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')]
35+
private bool $treatPhpDocTypesAsCertainTip,
3436
)
3537
{
3638
}
@@ -259,15 +261,35 @@ static function (Type $type) use ($typeMessageCallback): ?string {
259261
}
260262

261263
if ($expr instanceof Expr\NullsafePropertyFetch) {
262-
if ($expr->name instanceof Node\Identifier) {
263-
return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $expr->name->name, $operatorDescription))
264-
->identifier('nullsafe.neverNull')
265-
->build();
264+
// Only redundant when the operand itself is never null; otherwise the
265+
// ?-> produces the null that ??/isset()/empty() handles. A nullable
266+
// operand can still wrap a never-null one ($a?->b?->c), so recurse.
267+
$operandType = $this->treatPhpDocTypesAsCertain
268+
? $scope->getScopeType($expr->var)
269+
: $scope->getScopeNativeType($expr->var);
270+
if (!$operandType->isNull()->no()) {
271+
if ($expr->var instanceof Expr\NullsafePropertyFetch) {
272+
return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback);
273+
}
274+
275+
return null;
266276
}
267277

268-
return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->(Expression)" %s is unnecessary. Use -> instead.', $operatorDescription))
269-
->identifier('nullsafe.neverNull')
270-
->build();
278+
$message = $expr->name instanceof Node\Identifier
279+
? sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $expr->name->name, $operatorDescription)
280+
: sprintf('Using nullsafe property access "?->(Expression)" %s is unnecessary. Use -> instead.', $operatorDescription);
281+
282+
$ruleErrorBuilder = RuleErrorBuilder::message($message)->identifier('nullsafe.neverNull');
283+
284+
if (
285+
$this->treatPhpDocTypesAsCertain
286+
&& $this->treatPhpDocTypesAsCertainTip
287+
&& !$scope->getScopeNativeType($expr->var)->isNull()->no()
288+
) {
289+
$ruleErrorBuilder->treatPhpDocTypesAsCertainTip();
290+
}
291+
292+
return $ruleErrorBuilder->build();
271293
}
272294

273295
return null;

tests/PHPStan/Rules/Properties/data/bug-7109.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public function emptyNotFalsy2(): void
6262
public ?HelloWorld $prop = null;
6363
public function edgeCaseWithMethodCall(): void
6464
{
65-
// only ?->aaa should be reported
65+
// nothing to report: every ?-> operand can be null (phpstan/phpstan#14311)
6666
$this->get()?->prop?->get()?->aaa ?? 'edge';
6767
isset($this->get()?->prop?->get()?->aaa) ?: 'edge';
6868
empty($this->get()?->prop?->get()?->aaa) ?: 'edge';

tests/PHPStan/Rules/Variables/EmptyRuleTest.php

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ protected function getRule(): Rule
2525
new PropertyReflectionFinder(),
2626
true,
2727
$this->treatPhpDocTypesAsCertain,
28+
true,
2829
));
2930
}
3031

@@ -115,30 +116,14 @@ public function testBug7109(): void
115116
$this->treatPhpDocTypesAsCertain = true;
116117

117118
$this->analyse([__DIR__ . '/../Properties/data/bug-7109.php'], [
118-
[
119-
'Using nullsafe property access "?->aaa" in empty() is unnecessary. Use -> instead.',
120-
19,
121-
],
122-
[
123-
'Using nullsafe property access "?->aaa" in empty() is unnecessary. Use -> instead.',
124-
30,
125-
],
126119
[
127120
'Using nullsafe property access "?->aaa" in empty() is unnecessary. Use -> instead.',
128121
42,
129122
],
130-
[
131-
'Using nullsafe property access "?->notFalsy" in empty() is unnecessary. Use -> instead.',
132-
54,
133-
],
134123
[
135124
'Expression in empty() is not falsy.',
136125
59,
137126
],
138-
[
139-
'Using nullsafe property access "?->aaa" in empty() is unnecessary. Use -> instead.',
140-
68,
141-
],
142127
[
143128
'Using nullsafe property access "?->(Expression)" in empty() is unnecessary. Use -> instead.',
144129
75,

tests/PHPStan/Rules/Variables/IssetRuleTest.php

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ protected function getRule(): Rule
2424
new PropertyReflectionFinder(),
2525
true,
2626
$this->treatPhpDocTypesAsCertain,
27+
true,
2728
));
2829
}
2930

@@ -335,22 +336,10 @@ public function testBug7109(): void
335336
$this->treatPhpDocTypesAsCertain = true;
336337

337338
$this->analyse([__DIR__ . '/../Properties/data/bug-7109.php'], [
338-
[
339-
'Using nullsafe property access "?->aaa" in isset() is unnecessary. Use -> instead.',
340-
18,
341-
],
342-
[
343-
'Using nullsafe property access "?->aaa" in isset() is unnecessary. Use -> instead.',
344-
29,
345-
],
346339
[
347340
'Expression in isset() is not nullable.',
348341
41,
349342
],
350-
[
351-
'Using nullsafe property access "?->aaa" in isset() is unnecessary. Use -> instead.',
352-
67,
353-
],
354343
[
355344
'Expression in isset() is not nullable.',
356345
74,

tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ protected function getRule(): Rule
2323
new PropertyReflectionFinder(),
2424
true,
2525
$this->shouldTreatPhpDocTypesAsCertain(),
26+
true,
2627
), true);
2728
}
2829

@@ -263,25 +264,28 @@ public function testBug13623(): void
263264
public function testBug7109(): void
264265
{
265266
$this->analyse([__DIR__ . '/../Properties/data/bug-7109.php'], [
266-
[
267-
'Using nullsafe property access "?->aaa" on left side of ?? is unnecessary. Use -> instead.',
268-
17,
269-
],
270-
[
271-
'Using nullsafe property access "?->aaa" on left side of ?? is unnecessary. Use -> instead.',
272-
28,
273-
],
274267
[
275268
'Expression on left side of ?? is not nullable.',
276269
40,
277270
],
278271
[
279-
'Using nullsafe property access "?->aaa" on left side of ?? is unnecessary. Use -> instead.',
280-
66,
272+
'Expression on left side of ?? is not nullable.',
273+
73,
281274
],
275+
]);
276+
}
277+
278+
#[RequiresPhp('>= 8.0.0')]
279+
public function testBug14311(): void
280+
{
281+
$this->analyse([__DIR__ . '/data/bug-14311.php'], [
282282
[
283283
'Expression on left side of ?? is not nullable.',
284-
73,
284+
33,
285+
],
286+
[
287+
'Using nullsafe property access "?->inner" on left side of ?? is unnecessary. Use -> instead.',
288+
47,
285289
],
286290
]);
287291
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1); // lint >= 8.0
2+
3+
namespace Bug14311;
4+
5+
final class Node
6+
{
7+
8+
public int $id = 5;
9+
10+
}
11+
12+
function resolve(bool $flag): ?Node
13+
{
14+
return $flag ? new Node() : null;
15+
}
16+
17+
function make(): Node
18+
{
19+
return new Node();
20+
}
21+
22+
function coalesce(bool $flag): int
23+
{
24+
// $node is nullable, so the ?-> is required, not redundant.
25+
$node = resolve($flag);
26+
27+
return $node?->id ?? 0;
28+
}
29+
30+
function neverNullOperand(): int
31+
{
32+
// make() never returns null, so the ?-> is redundant.
33+
return make()?->id ?? 0;
34+
}
35+
36+
final class Outer
37+
{
38+
39+
public ?Node $inner = null;
40+
41+
}
42+
43+
function chain(Outer $outer): int
44+
{
45+
// $outer is never null so ?->inner is redundant; $outer->inner is
46+
// nullable so ?->id is required.
47+
return $outer?->inner?->id ?? 0;
48+
}

0 commit comments

Comments
 (0)