diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index aefe7850be2..90fa162f056 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4364,7 +4364,7 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback): ExpressionResult { $assignHandler = $this->container->getByType(AssignHandler::class); - $virtualAssignNodeCallback = new VirtualAssignNodeCallback($nodeCallback); + $virtualAssignNodeCallback = VirtualAssignNodeCallback::create($nodeCallback); $target = $assignHandler->prepareTarget( $this, $scope, diff --git a/src/Analyser/VirtualAssignNodeCallback.php b/src/Analyser/VirtualAssignNodeCallback.php index 13d4cff9101..c4c4bdf8164 100644 --- a/src/Analyser/VirtualAssignNodeCallback.php +++ b/src/Analyser/VirtualAssignNodeCallback.php @@ -12,10 +12,31 @@ final class VirtualAssignNodeCallback implements ShallowNodeCallback /** * @param callable(Node $node, Scope $scope): void $originalNodeCallback */ - public function __construct(private mixed $originalNodeCallback) + private function __construct(private mixed $originalNodeCallback) { } + /** + * Rebuilds the chain instead of wrapping it so that GatheringNodeCallback + * layers stay on the outside. Hiding a gatherer behind this filter would let + * FiberNodeScopeResolver defer it into a fiber, and a parked fiber can run + * the gatherer long after the caller already read its result. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + * @return callable(Node $node, Scope $scope): void + */ + public static function create(callable $nodeCallback): callable + { + if ($nodeCallback instanceof GatheringNodeCallback) { + return new GatheringNodeCallback( + self::create($nodeCallback->getGatherer()), + self::create($nodeCallback->getInner()), + ); + } + + return new self($nodeCallback); + } + public function __invoke(Node $node, Scope $scope): void { if (!$node instanceof PropertyAssignNode && !$node instanceof VariableAssignNode) { diff --git a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php index ea72fe685e1..13bd5ea3874 100644 --- a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php @@ -2,10 +2,15 @@ namespace PHPStan\Rules\DeadCode; +use PhpParser\Node; +use PHPStan\Analyser\Scope; +use PHPStan\Collectors\Collector; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\Printer\Printer; +use PHPStan\Node\PropertyAssignNode; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\RequiresPhp; /** @@ -19,6 +24,30 @@ protected function getRule(): Rule return new NoopRule(new ExprPrinter(new Printer())); } + protected function getCollectors(): array + { + // Asks for the type of a not-yet-walked expression, the same way + // TypesAssignedToPropertiesRule does, so FiberNodeScopeResolver parks + // the node callback on PropertyAssignNode. The hasAssign gatherer + // feeding NoopExpressionNode must not be delayed by that + // (https://github.com/phpstan/phpstan/issues/15038). + return [ + new /** @implements Collector */ class implements Collector { + + public function getNodeType(): string + { + return PropertyAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): string + { + return $scope->getType($node->getAssignedExpr())->describe(VerbosityLevel::typeOnly()); + } + + }, + ]; + } + public function testRule(): void { $this->analyse([__DIR__ . '/data/noop.php'], [ @@ -173,6 +202,12 @@ public function testBug13698(): void ]); } + #[RequiresPhp('>= 8.4.0')] + public function testBug15038(): void + { + $this->analyse([__DIR__ . '/data/bug-15038.php'], []); + } + #[RequiresPhp('>= 8.5.0')] public function testPipeOperator(): void { diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-15038.php b/tests/PHPStan/Rules/DeadCode/data/bug-15038.php new file mode 100644 index 00000000000..3a7c78df5be --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-15038.php @@ -0,0 +1,84 @@ += 8.4 + +namespace Bug15038; + +enum Counter { + case A; + case B; +} + +class Statistics { + public private(set) int $counterA = 0; + public private(set) int $counterB = 0; + + public function inc(Counter $counter): void { + match ($counter) { + Counter::A => $this->counterA++, + Counter::B => $this->counterB++, + }; + } +} + +$s = new Statistics(); +$s->inc(Counter::A); +$s->inc(Counter::A); +$s->inc(Counter::A); + +var_dump($s->counterA); + +class MoreVirtualAssigns +{ + + public int $a = 0; + + public int $b = 0; + + /** @var array{a: int, b: int} */ + public array $arr = ['a' => 0, 'b' => 0]; + + public function postDec(Counter $counter): void + { + match ($counter) { + Counter::A => $this->a--, + Counter::B => $this->b--, + }; + } + + public function preIncDec(Counter $counter): void + { + match ($counter) { + Counter::A => ++$this->a, + Counter::B => --$this->b, + }; + } + + public function offset(Counter $counter): void + { + match ($counter) { + Counter::A => $this->arr['a']++, + Counter::B => $this->arr['b']--, + }; + } + + public function nestedMatch(Counter $counter): void + { + match ($counter) { + Counter::A => match (true) { + default => $this->a++, + }, + Counter::B => $this->b++, + }; + } + + public function booleanOperators(bool $cond): void + { + $cond && ($this->a++ > 0); + $cond || ($this->b-- > 0); + } + + public function arrayLiteral(): void + { + [$this->a++]; + } + +}