Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 59 additions & 24 deletions src/Analyser/NodeCallbackScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Closure(MutatingScope): MutatingScope>
*/
private array $scopeOps = [];

private ?MutatingScope $walkScope = null;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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<string> $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;
}
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
108 changes: 108 additions & 0 deletions tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use LogicException;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use function count;
use function is_string;
use function sprintf;

/**
* Exercises scope-deriving mutators (assignExpression(), assignVariable())
* on the scope a rule received, the way third-party rules do: deriving a
* locally modified scope and expecting getType() on it to see the
* modification — including combined with filterByTruthyValue() and after an
* earlier getType() call already answered from the unmodified scope.
*
* @implements Rule<FuncCall>
*/
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();
}

}
41 changes: 41 additions & 0 deletions tests/PHPStan/Analyser/NodeCallbackScopeDerivedOpsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<NodeCallbackScopeDerivedOpsRule>
*/
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,
],
]);
}

}
39 changes: 39 additions & 0 deletions tests/PHPStan/Analyser/data/node-callback-scope-derived-ops.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);

namespace NodeCallbackScopeDerivedOps;

function probeAssignExpression(string $subject): void
{
}

function probeAssignExpressionAfterRead(string $subject): void
{
}

function probeAssignVariable(string $subject): void
{
}

function probeFilterThenAssign(bool $condition, ?string $subject): void
{
}

function testAssignExpression(string $key): void
{
probeAssignExpression($key);
}

function testAssignExpressionAfterRead(string $key): void
{
probeAssignExpressionAfterRead($key);
}

function testAssignVariable(string $key): void
{
probeAssignVariable($key);
}

function testFilterThenAssign(?string $key): void
{
probeFilterThenAssign($key !== null, $key);
}
Loading