Skip to content
Open
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
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ parameters:
tips:
discoveringSymbols: true
treatPhpDocTypesAsCertain: true
possiblyImpure: true
tipsOfTheDay: true
reportMagicMethods: false
reportMagicProperties: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ parametersSchema:
tips: structure([
discoveringSymbols: bool()
treatPhpDocTypesAsCertain: bool()
possiblyImpure: bool()
])
tipsOfTheDay: bool()
reportMaybes: bool()
Expand Down
101 changes: 101 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr;
use PHPStan\Node\Expr\SetOffsetValueTypeExpr;
Expand Down Expand Up @@ -144,6 +145,7 @@
use Throwable;
use ValueError;
use function abs;
use function assert;
use function array_filter;
use function array_key_exists;
use function array_key_first;
Expand All @@ -153,6 +155,7 @@
use function array_merge;
use function array_pop;
use function array_slice;
use function array_unique;
use function array_values;
use function count;
use function explode;
Expand Down Expand Up @@ -778,6 +781,100 @@ public function getMaybeDefinedVariables(): array
return $variables;
}

/**
* @return list<string>
*/
public function findPossiblyImpureCallDescriptions(Expr $expr): array
{
$nodeFinder = new NodeFinder();
$descriptions = [];
$foundCallExprMatch = false;
foreach ($this->expressionTypes as $holder) {
$holderExpr = $holder->getExpr();
if (!$holderExpr instanceof PossiblyImpureCallExpr) {
continue;
}

$callExprKey = $this->getNodeKey($holderExpr->callExpr);

$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($callExprKey): bool {
if (!$node instanceof Expr) {
return false;
}

return $this->getNodeKey($node) === $callExprKey;
});

if ($found === null) {
continue;
}

$foundCallExprMatch = true;

// Only show the tip when the scope's type for the call expression
// differs from the declared return type, meaning control flow
// narrowing affected the type (the cached value was narrowed).
assert($found instanceof Expr);
$scopeType = $this->getType($found);
$declaredReturnType = $holder->getType();
if ($declaredReturnType->isSuperTypeOf($scopeType)->yes() && $scopeType->isSuperTypeOf($declaredReturnType)->yes()) {
continue;
}

$descriptions[] = $holderExpr->getCallDescription();
}

if (count($descriptions) > 0) {
return array_values(array_unique($descriptions));
}

// If the first pass found a callExpr in the error expression but
// filtered it out (return type wasn't narrowed), the error is
// explained by the return type alone - skip the fallback.
if ($foundCallExprMatch) {
return [];
}

// Fallback: match by impactedExpr for cases where a maybe-impure method
// on an object didn't invalidate it, but a different method's return
// value was narrowed on that object.
// Skip when the expression itself is a direct method/static call -
// those are passed by ImpossibleCheckType rules where the error is
// about the call's arguments, not about object state.
if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\StaticCall) {
return [];
}
foreach ($this->expressionTypes as $holder) {
$holderExpr = $holder->getExpr();
if (!$holderExpr instanceof PossiblyImpureCallExpr) {
continue;
}

$impactedExprKey = $this->getNodeKey($holderExpr->impactedExpr);

// Skip if impactedExpr is the same as callExpr (function calls)
if ($impactedExprKey === $this->getNodeKey($holderExpr->callExpr)) {
continue;
}

$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($impactedExprKey): bool {
if (!$node instanceof Expr) {
return false;
}

return $this->getNodeKey($node) === $impactedExprKey;
});

if ($found === null) {
continue;
}

$descriptions[] = $holderExpr->getCallDescription();
}

return array_values(array_unique($descriptions));
}

private function isGlobalVariable(string $variableName): bool
{
return in_array($variableName, self::SUPERGLOBAL_VARIABLES, true);
Expand Down Expand Up @@ -953,6 +1050,10 @@ private function getClosureScopeCacheKey(): string
$parts[] = sprintf(',%s', $parameter->getType()->describe(VerbosityLevel::cache()));
}

if ($this->nativeTypesPromoted) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this fix is right. I think instead it should go over $this->nativeExpressionTypes same way it currently does over $this->expressionTypes.

$parts[] = '::native';
}

return md5(implode("\n", $parts));
}

Expand Down
37 changes: 37 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
use PHPStan\Node\Expr\NativeTypeExpr;
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr;
use PHPStan\Node\Expr\SetOffsetValueTypeExpr;
Expand Down Expand Up @@ -291,6 +292,8 @@ public function __construct(
private readonly bool $implicitThrows,
#[AutowiredParameter]
private readonly bool $treatPhpDocTypesAsCertain,
#[AutowiredParameter]
private readonly bool $rememberPossiblyImpureFunctionValues,
)
{
$earlyTerminatingMethodNames = [];
Expand Down Expand Up @@ -2918,6 +2921,20 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
$scope = $scope->invalidateExpression(new Variable('this'), true);
}

if (
$functionReflection !== null
&& $this->rememberPossiblyImpureFunctionValues
&& $parametersAcceptor !== null
&& $functionReflection->hasSideEffects()->maybe()
&& !$functionReflection->isBuiltin()
) {
$scope = $scope->assignExpression(
new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr, sprintf('%s()', $functionReflection->getName())),
$parametersAcceptor->getReturnType(),
new MixedType(),
);
}

if (
$functionReflection !== null
&& in_array($functionReflection->getName(), ['json_encode', 'json_decode'], true)
Expand Down Expand Up @@ -3216,6 +3233,12 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) {
$this->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage);
$scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass());
} elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() && $parametersAcceptor !== null) {
$scope = $scope->assignExpression(
new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())),
$parametersAcceptor->getReturnType(),
new MixedType(),
);
}
if ($parametersAcceptor !== null && !$methodReflection->isStatic()) {
$selfOutType = $methodReflection->getSelfOutType();
Expand Down Expand Up @@ -3426,6 +3449,20 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
&& $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName())
) {
$scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass());
} elseif (
$methodReflection !== null
&& $this->rememberPossiblyImpureFunctionValues
&& $parametersAcceptor !== null
&& $scope->isInClass()
Comment on lines +3455 to +3456
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 2 lines should be moved above $methodReflection->hasSideEffects()->maybe()

&& $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName())
&& $methodReflection->hasSideEffects()->maybe()
&& !$methodReflection->getDeclaringClass()->isBuiltin()
) {
$scope = $scope->assignExpression(
new PossiblyImpureCallExpr($normalizedExpr, new Variable('this'), sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())),
$parametersAcceptor->getReturnType(),
new MixedType(),
);
}

if (
Expand Down
41 changes: 41 additions & 0 deletions src/Node/Expr/PossiblyImpureCallExpr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node\Expr;

use Override;
use PhpParser\Node\Expr;
use PHPStan\Node\VirtualNode;

final class PossiblyImpureCallExpr extends Expr implements VirtualNode
{

public function __construct(
public Expr $callExpr,
public Expr $impactedExpr,
private string $callDescription,
)
{
parent::__construct([]);
}

public function getCallDescription(): string
{
return $this->callDescription;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_PossiblyImpureCallExpr';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return ['callExpr', 'impactedExpr'];
}

}
6 changes: 6 additions & 0 deletions src/Node/Printer/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr;
use PHPStan\Node\Expr\SetOffsetValueTypeExpr;
Expand Down Expand Up @@ -88,6 +89,11 @@ protected function pPHPStan_Node_AlwaysRememberedExpr(AlwaysRememberedExpr $expr
return sprintf('__phpstanRemembered(%s)', $this->p($expr->getExpr()));
}

protected function pPHPStan_Node_PossiblyImpureCallExpr(PossiblyImpureCallExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanPossiblyImpure(%s, %s)', $this->p($expr->callExpr), $this->p($expr->impactedExpr));
}

protected function pPHPStan_Node_PropertyInitializationExpr(PropertyInitializationExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanPropertyInitialization(%s)', $expr->getPropertyName());
Expand Down
31 changes: 19 additions & 12 deletions src/Rules/Comparison/BooleanAndConstantConditionRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class BooleanAndConstantConditionRule implements Rule

public function __construct(
private ConstantConditionRuleHelper $helper,
private PossiblyImpureTipHelper $possiblyImpureTipHelper,
#[AutowiredParameter]
private bool $treatPhpDocTypesAsCertain,
#[AutowiredParameter]
Expand Down Expand Up @@ -51,18 +52,20 @@ public function processNode(
if ($leftType instanceof ConstantBooleanType) {
$addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder {
if (!$this->treatPhpDocTypesAsCertain) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode->left, $ruleErrorBuilder);
}

$booleanNativeType = $this->helper->getNativeBooleanType($scope, $originalNode->left);
if ($booleanNativeType instanceof ConstantBooleanType) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode->left, $ruleErrorBuilder);
}
if (!$this->treatPhpDocTypesAsCertainTip) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode->left, $ruleErrorBuilder);
}

return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();
$ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();

return $this->possiblyImpureTipHelper->addTip($scope, $originalNode->left, $ruleErrorBuilder);
};

$isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME);
Expand All @@ -89,21 +92,23 @@ public function processNode(
if ($rightType instanceof ConstantBooleanType && !$scope->isInFirstLevelStatement()) {
$addTipRight = function (RuleErrorBuilder $ruleErrorBuilder) use ($rightScope, $originalNode): RuleErrorBuilder {
if (!$this->treatPhpDocTypesAsCertain) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($rightScope, $originalNode->right, $ruleErrorBuilder);
}

$booleanNativeType = $this->helper->getNativeBooleanType(
$rightScope,
$originalNode->right,
);
if ($booleanNativeType instanceof ConstantBooleanType) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($rightScope, $originalNode->right, $ruleErrorBuilder);
}
if (!$this->treatPhpDocTypesAsCertainTip) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($rightScope, $originalNode->right, $ruleErrorBuilder);
}

return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();
$ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();

return $this->possiblyImpureTipHelper->addTip($rightScope, $originalNode->right, $ruleErrorBuilder);
};

$isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME);
Expand All @@ -127,18 +132,20 @@ public function processNode(
if ($nodeType instanceof ConstantBooleanType) {
$addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder {
if (!$this->treatPhpDocTypesAsCertain) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode, $ruleErrorBuilder);
}

$booleanNativeType = $scope->getNativeType($originalNode);
if ($booleanNativeType instanceof ConstantBooleanType) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode, $ruleErrorBuilder);
}
if (!$this->treatPhpDocTypesAsCertainTip) {
return $ruleErrorBuilder;
return $this->possiblyImpureTipHelper->addTip($scope, $originalNode, $ruleErrorBuilder);
}

return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();
$ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip();

return $this->possiblyImpureTipHelper->addTip($scope, $originalNode, $ruleErrorBuilder);
};

$isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME);
Expand Down
Loading
Loading