diff --git a/src/Analyser/NodeCallbackScope.php b/src/Analyser/NodeCallbackScope.php index a5bc7506d9..00ec7b8436 100644 --- a/src/Analyser/NodeCallbackScope.php +++ b/src/Analyser/NodeCallbackScope.php @@ -2,11 +2,14 @@ namespace PHPStan\Analyser; +use Closure; use PhpParser\Node\Expr; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use function array_pop; use function count; @@ -15,11 +18,19 @@ final class NodeCallbackScope extends MutatingScope { - /** @var Expr[] */ - private array $truthyValueExprs = []; - - /** @var Expr[] */ - private array $falseyValueExprs = []; + /** + * Scope-deriving calls a rule made on this scope, in call order. Asks are + * answered from the asked node's stored before-scope (see doGetType()), + * which knows nothing about what the rule derived locally — so every + * deriving call is recorded here and replayed onto the before-scope + * before it answers (preprocessScope()). Only top-level calls are + * recorded: a mutator implemented via other mutators (assignVariable() + * calls assignExpression()) resets the list to its own caller's view and + * appends itself, and the replay re-runs the composition. + * + * @var list + */ + private array $scopeOps = []; private ?MutatingScope $walkScope = null; @@ -126,8 +137,7 @@ private function doGetType(Expr $node): Type if ( !$this->nativeTypesPromoted - && count($this->truthyValueExprs) === 0 - && count($this->falseyValueExprs) === 0 + && count($this->scopeOps) === 0 ) { if ($beforeScope !== null) { return $beforeScope->getType($node); @@ -175,8 +185,7 @@ private function doGetNativeType(Expr $expr): Type if ( !$this->nativeTypesPromoted - && count($this->truthyValueExprs) === 0 - && count($this->falseyValueExprs) === 0 + && count($this->scopeOps) === 0 ) { if ($beforeScope !== null) { return $beforeScope->getNativeType($expr); @@ -202,9 +211,8 @@ public function filterByTruthyValue(Expr $expr): self { /** @var self $scope */ $scope = parent::filterByTruthyValue($expr); - $scope->truthyValueExprs = $this->truthyValueExprs; - $scope->falseyValueExprs = $this->falseyValueExprs; - $scope->truthyValueExprs[] = $expr; + $scope->scopeOps = $this->scopeOps; + $scope->scopeOps[] = static fn (MutatingScope $scope): MutatingScope => $scope->filterByTruthyValue($expr); return $scope; } @@ -213,9 +221,41 @@ public function filterByFalseyValue(Expr $expr): self { /** @var self $scope */ $scope = parent::filterByFalseyValue($expr); - $scope->truthyValueExprs = $this->truthyValueExprs; - $scope->falseyValueExprs = $this->falseyValueExprs; - $scope->falseyValueExprs[] = $expr; + $scope->scopeOps = $this->scopeOps; + $scope->scopeOps[] = static fn (MutatingScope $scope): MutatingScope => $scope->filterByFalseyValue($expr); + + return $scope; + } + + /** + * @param list $intertwinedPropagatedFrom + */ + public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = []): self + { + /** @var self $scope */ + $scope = parent::assignVariable($variableName, $type, $nativeType, $certainty, $intertwinedPropagatedFrom); + $scope->scopeOps = $this->scopeOps; + $scope->scopeOps[] = static fn (MutatingScope $scope): MutatingScope => $scope->assignVariable($variableName, $type, $nativeType, $certainty, $intertwinedPropagatedFrom); + + return $scope; + } + + public function assignExpression(Expr $expr, Type $type, Type $nativeType): self + { + /** @var self $scope */ + $scope = parent::assignExpression($expr, $type, $nativeType); + $scope->scopeOps = $this->scopeOps; + $scope->scopeOps[] = static fn (MutatingScope $scope): MutatingScope => $scope->assignExpression($expr, $type, $nativeType); + + return $scope; + } + + public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self + { + /** @var self $scope */ + $scope = parent::invalidateExpression($expressionToInvalidate, $requireMoreCharacters, $invalidatingClass); + $scope->scopeOps = $this->scopeOps; + $scope->scopeOps[] = static fn (MutatingScope $scope): MutatingScope => $scope->invalidateExpression($expressionToInvalidate, $requireMoreCharacters, $invalidatingClass); return $scope; } @@ -230,11 +270,8 @@ private function preprocessScope(MutatingScope $scope): Scope $scope = $scope->doNotTreatPhpDocTypesAsCertain(); } - foreach ($this->truthyValueExprs as $expr) { - $scope = $scope->filterByTruthyValue($expr); - } - foreach ($this->falseyValueExprs as $expr) { - $scope = $scope->filterByFalseyValue($expr); + foreach ($this->scopeOps as $op) { + $scope = $op($scope); } return $scope; @@ -247,8 +284,7 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, { /** @var self $scope */ $scope = parent::pushInFunctionCall($reflection, $parameter, $rememberTypes); - $scope->truthyValueExprs = $this->truthyValueExprs; - $scope->falseyValueExprs = $this->falseyValueExprs; + $scope->scopeOps = $this->scopeOps; return $scope; } @@ -260,8 +296,7 @@ public function popInFunctionCall(): self /** @var self $scope */ $scope = parent::popInFunctionCall(); - $scope->truthyValueExprs = $this->truthyValueExprs; - $scope->falseyValueExprs = $this->falseyValueExprs; + $scope->scopeOps = $this->scopeOps; return $scope; } diff --git a/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRule.php b/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRule.php new file mode 100644 index 0000000000..378d744440 --- /dev/null +++ b/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRule.php @@ -0,0 +1,108 @@ + + */ +class NodeCallbackScopeDerivedOpsRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + if (!$scope instanceof MutatingScope) { + throw new LogicException('Expected MutatingScope'); + } + + $functionName = $node->name->getLast(); + $args = $node->getArgs(); + if (count($args) === 0) { + return []; + } + + $var = $args[count($args) - 1]->value; + if (!$var instanceof Variable || !is_string($var->name)) { + return []; + } + + $assignedType = new ConstantStringType('assigned'); + + if ($functionName === 'probeAssignExpression') { + $assignedScope = $scope->assignExpression($var, $assignedType, $assignedType); + + return [$this->describe('assigned', $assignedScope->getType($var))]; + } + + if ($functionName === 'probeAssignExpressionAfterRead') { + $before = $scope->getType($var); + $assignedScope = $scope->assignExpression($var, $assignedType, $assignedType); + + return [$this->describe(sprintf( + 'before %s, after', + $before->describe(VerbosityLevel::precise()), + ), $assignedScope->getType($var))]; + } + + if ($functionName === 'probeAssignVariable') { + $assignedScope = $scope->assignVariable($var->name, $assignedType, $assignedType, TrinaryLogic::createYes()); + + return [$this->describe('assigned', $assignedScope->getType($var))]; + } + + if ($functionName === 'probeFilterThenAssign') { + if (count($args) < 2) { + return []; + } + + $filteredScope = $scope->filterByTruthyValue($args[0]->value); + $filteredThenAssignedScope = $filteredScope->assignExpression($var, $assignedType, $assignedType); + + return [$this->describe(sprintf( + 'filtered %s, assigned', + $filteredScope->getType($var)->describe(VerbosityLevel::precise()), + ), $filteredThenAssignedScope->getType($var))]; + } + + return []; + } + + private function describe(string $prefix, Type $type): IdentifierRuleError + { + return RuleErrorBuilder::message(sprintf( + '%s: %s', + $prefix, + $type->describe(VerbosityLevel::precise()), + ))->identifier('tests.nodeCallbackScopeDerivedOps')->build(); + } + +} diff --git a/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRuleTest.php b/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRuleTest.php new file mode 100644 index 0000000000..e2dfb62c1a --- /dev/null +++ b/tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRuleTest.php @@ -0,0 +1,41 @@ + + */ +class NodeCallbackScopeDerivedOpsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NodeCallbackScopeDerivedOpsRule(); + } + + public function testDerivedOps(): void + { + $this->analyse([__DIR__ . '/data/node-callback-scope-derived-ops.php'], [ + [ + 'assigned: \'assigned\'', + 23, + ], + [ + 'before string, after: \'assigned\'', + 28, + ], + [ + 'assigned: \'assigned\'', + 33, + ], + [ + 'filtered string, assigned: \'assigned\'', + 38, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/node-callback-scope-derived-ops.php b/tests/PHPStan/Analyser/data/node-callback-scope-derived-ops.php new file mode 100644 index 0000000000..c0bb48ec76 --- /dev/null +++ b/tests/PHPStan/Analyser/data/node-callback-scope-derived-ops.php @@ -0,0 +1,39 @@ +