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 @@ -104,6 +104,7 @@ parameters:
reportStaticMethodSignatures: false
reportWrongPhpDocTypeInVarTag: false
reportAnyTypeWideningInVarTag: false
reportRedundantVarTag: false
reportNonIntStringArrayKey: false
reportUnsafeArrayStringKeyCasting: null
reportPossiblyNonexistentGeneralArrayOffset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ parametersSchema:
reportStaticMethodSignatures: bool()
reportWrongPhpDocTypeInVarTag: bool()
reportAnyTypeWideningInVarTag: bool()
reportRedundantVarTag: bool()
reportNonIntStringArrayKey: bool()
reportUnsafeArrayStringKeyCasting: schema(string(), pattern('detect|prevent'), nullable())
reportPossiblyNonexistentGeneralArrayOffset: bool()
Expand Down
1 change: 1 addition & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ public function processStmtNode(
if ($stmtHandler !== null) {
$stmtResult = $stmtHandler->processStmt($this, $stmt, $scope, $storage, $nodeCallback, $context);
if ($varTagDependencies !== null) {
$this->statementsHandler->emitRedundantVarTagNodes($this, $scope, $storage, $stmt, $nodeCallback);
$stmtResult = $stmtResult->withDependencies(Dependencies::merge($stmtResult->getDependencies(), $varTagDependencies));
}
if ($overridingThrowPoints !== null) {
Expand Down
183 changes: 183 additions & 0 deletions src/Analyser/RedundantVarTagDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\RedundantVarTagNode;
use PHPStan\PhpDoc\Tag\VarTag;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\ErrorType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use function is_string;
use function spl_object_id;

/**
* Finds the inline @var tags that change nothing about the variable or
* expression they re-type, for the opt-in reportRedundantVarTag.
*
* The walk applies a tag every time it walks the tag's statement: in every
* pass over a loop body, in the observation pass of template arguments, in
* every iteration over a closure with by-reference uses. Its own type can flow
* back into what it re-types in a later pass, so a tag is redundant only when
* none of these applications changed anything. The engine records each one,
* and emits the redundant tags once it has processed the statement.
*
* Only the type and the certainty are compared: an assignment's tag also makes
* the native type mixed and forgets what the assigned value narrowed, and
* without the tag both would only be more precise.
*/
#[AutowiredService]
final class RedundantVarTagDetector implements PerFileAnalysisResettable
{

private const EXPLICIT_MIXED = 1;
private const IMPLICIT_MIXED = 2;
private const EXPLICIT_NEVER = 4;
private const IMPLICIT_NEVER = 8;

/** @var array<int, array{Node\Stmt, array<int|string, array{VarTag, bool}>}> statement id => [statement, tag key => [tag, changed nothing so far]] */
private array $applications = [];

public function resetFileAnalysisState(): void
{
$this->applications = [];
}

/**
* Records the application of $varTag to a variable, which gets the tag's
* type, and becomes certainly defined when $defines is true. $narrowedByForeach
* is true for the tag of a foreach's iteratee applied again on entering the
* loop body, where the loop may have narrowed the variable to a non-empty
* array the tag widens again.
*
* @param int|string $key the tag's key in ResolvedPhpDocBlock::getVarTags()
*/
public function recordVariable(Node\Stmt $stmt, $key, VarTag $varTag, MutatingScope $scope, string $variableName, bool $defines, bool $narrowedByForeach = false): void
{
$certainty = $scope->hasVariableType($variableName);
if ($certainty->no() || ($defines && !$certainty->yes())) {
$this->record($stmt, $key, $varTag, false);
return;
}

$originalType = $scope->getVariableType($variableName);
$tagType = $varTag->getType();
$this->record(
$stmt,
$key,
$varTag,
$this->isSameType($originalType, $tagType)
|| ($narrowedByForeach && $this->isSameType($originalType, TypeCombinator::remove($tagType, new ConstantArrayType([], [])))),
);
}

/**
* Records the application of the variable-less $varTag to $expr, $scope
* being the scope before the tag re-typed it.
*/
public function recordExpression(NodeScopeResolver $nodeScopeResolver, Node\Stmt $stmt, VarTag $varTag, Expr $expr, MutatingScope $scope): void
{
// priced again from scratch: the walk's results of the expression and
// of its parts saw the tag's type, re-typing $a['k'] narrows $a as well
$originalType = $nodeScopeResolver->processExprOnDemand($expr, $scope, new ExpressionResultStorage())->getType();
$this->record($stmt, 0, $varTag, $this->isSameType($originalType, $varTag->getType()));
}

/**
* Emits a RedundantVarTagNode for each tag above $stmt that changed
* nothing in any of its applications so far.
*
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
public function emitNodes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, callable $nodeCallback): void
{
$id = spl_object_id($stmt);
if (!isset($this->applications[$id]) || $this->applications[$id][0] !== $stmt) {
return;
}

foreach ($this->applications[$id][1] as $key => [$varTag, $changedNothing]) {
if (!$changedNothing) {
continue;
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new RedundantVarTagNode($stmt, $varTag, is_string($key) ? $key : null), $scope, $storage);
}
}

/**
* @param int|string $key
*/
private function record(Node\Stmt $stmt, $key, VarTag $varTag, bool $changedNothing): void
{
$id = spl_object_id($stmt);
if (!isset($this->applications[$id]) || $this->applications[$id][0] !== $stmt) {
// the id of a statement freed since is reused
$this->applications[$id] = [$stmt, []];
}

$changedNothingBefore = $this->applications[$id][1][$key][1] ?? true;
$this->applications[$id][1][$key] = [$varTag, $changedNothingBefore && $changedNothing];
}

private function isSameType(Type $originalType, Type $tagType): bool
{
if (
// equals() is one-way for a type extending another one, like a
// template type or a benevolent union, and does not compare
// whether a constant array is a list
!$originalType->equals($tagType)
|| !$tagType->equals($originalType)
|| !$originalType->isList()->equals($tagType->isList())
) {
return false;
}

// equals() also ignores whether mixed and never are explicit, which
// decides what is reported about them
$kinds = $this->getMixedAndNeverKinds($originalType);

return $kinds !== null && $kinds === $this->getMixedAndNeverKinds($tagType);
}

/**
* Which kinds of mixed and never the type contains; null when it contains an
* error type, or both kinds of mixed or of never, whose positions are not compared.
*/
private function getMixedAndNeverKinds(Type $type): ?int
{
$kinds = 0;
$containsError = false;
TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$kinds, &$containsError): Type {
if ($type instanceof ErrorType) {
$containsError = true;
return $type;
}

if ($type instanceof MixedType) {
$kinds |= $type->isExplicitMixed() ? self::EXPLICIT_MIXED : self::IMPLICIT_MIXED;
} elseif ($type instanceof NeverType) {
$kinds |= $type->isExplicit() ? self::EXPLICIT_NEVER : self::IMPLICIT_NEVER;
}

return $traverse($type);
});

if (
$containsError
|| ($kinds & (self::EXPLICIT_MIXED | self::IMPLICIT_MIXED)) === (self::EXPLICIT_MIXED | self::IMPLICIT_MIXED)
|| ($kinds & (self::EXPLICIT_NEVER | self::IMPLICIT_NEVER)) === (self::EXPLICIT_NEVER | self::IMPLICIT_NEVER)
) {
return null;
}

return $kinds;
}

}
26 changes: 26 additions & 0 deletions src/Analyser/StatementsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ public function __construct(
private TemplateArgumentResolver $templateArgumentResolver,
private Container $container,
private StaticVariableInference $staticVariableInference,
private RedundantVarTagDetector $redundantVarTagDetector,
#[AutowiredParameter(ref: '%featureToggles.unresolvedTemplateArguments%')]
private bool $unresolvedTemplateArguments,
#[AutowiredParameter(ref: '%reportRedundantVarTag%')]
private bool $reportRedundantVarTag,
)
{
$this->debugTemplateArguments = getenv('PHPSTAN_TEMPLATE_ARGUMENTS_DEBUG') === '1';
Expand Down Expand Up @@ -1977,6 +1980,10 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M
$scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($varTag->getType(), $originalType));
}

if ($this->reportRedundantVarTag) {
$this->redundantVarTagDetector->recordVariable($stmt, $name, $varTag, $scope, $name, $certainty->yes());
}

$nativeScope = $scope->doNotTreatPhpDocTypesAsCertain();
$scope = $scope->assignVariable(
$name,
Expand Down Expand Up @@ -2047,11 +2054,30 @@ public function emitVarTagChangedNode(NodeScopeResolver $nodeScopeResolver, Muta
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage);
if ($this->reportRedundantVarTag) {
$this->redundantVarTagDetector->recordExpression($nodeScopeResolver, $stmt, $varTag, $defaultExpr, $scope);
}
$defaultExprResult = $storage->findExpressionResult($defaultExpr);
if ($defaultExprResult === null || $nodeScopeResolver->observingTemplateArgumentFrame($defaultExprResult->getScope()) === null) {
return TemplateArgumentConstraints::createEmpty();
}
return $this->templateArgumentObserver->collectSend($varTag->getType(), $defaultExprResult->getType());
}

/**
* Emits the nodes the redundant @var tag rule listens to, for the tags above
* $stmt that changed nothing in any application so far - called once the
* statement has been processed (see RedundantVarTagDetector).
*
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
public function emitRedundantVarTagNodes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, callable $nodeCallback): void
{
if (!$this->reportRedundantVarTag) {
return;
}

$this->redundantVarTagDetector->emitNodes($nodeScopeResolver, $scope, $storage, $stmt, $nodeCallback);
}

}
2 changes: 1 addition & 1 deletion src/Analyser/StmtHandler/ForeachHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ static function () use ($condResult, $emptyArrayType): Type {
private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, Type $iterateeType, Type $nativeIterateeType, callable $nodeCallback): MutatingScope
{
if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) {
$scope = $this->varAnnotationProcessor->processVarAnnotation($scope, [$stmt->expr->name], $stmt);
$scope = $this->varAnnotationProcessor->processForeachIterateeVarAnnotation($scope, $stmt->expr->name, $stmt);
}

if (
Expand Down
29 changes: 29 additions & 0 deletions src/Analyser/VarAnnotationProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PhpParser\Comment\Doc;
use PhpParser\Node;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\TrinaryLogic;
use PHPStan\Turbo\ShadowedByTurboExtension;
Expand All @@ -18,6 +19,9 @@ final class VarAnnotationProcessor

public function __construct(
private FileTypeMapper $fileTypeMapper,
private RedundantVarTagDetector $redundantVarTagDetector,
#[AutowiredParameter(ref: '%reportRedundantVarTag%')]
private bool $reportRedundantVarTag,
)
{
}
Expand All @@ -26,6 +30,25 @@ public function __construct(
* @param array<int, string> $variableNames
*/
public function processVarAnnotation(MutatingScope $scope, array $variableNames, Node\Stmt $node, bool &$changed = false): MutatingScope
{
return $this->applyVarTags($scope, $variableNames, $node, $changed, false);
}

/**
* processVarAnnotation() for the variable a foreach iterates, applied again on
* entering the loop body, where the loop may have narrowed it to a non-empty array.
*/
public function processForeachIterateeVarAnnotation(MutatingScope $scope, string $variableName, Node\Stmt\Foreach_ $node): MutatingScope
{
$changed = false;

return $this->applyVarTags($scope, [$variableName], $node, $changed, true);
}

/**
* @param array<int, string> $variableNames
*/
private function applyVarTags(MutatingScope $scope, array $variableNames, Node\Stmt $node, bool &$changed, bool $narrowedByForeach): MutatingScope
{
$function = $scope->getFunction();
$varTags = [];
Expand Down Expand Up @@ -57,12 +80,18 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames,

$variableType = $varTags[$variableName]->getType();
$changed = true;
if ($this->reportRedundantVarTag) {
$this->redundantVarTagDetector->recordVariable($node, $variableName, $varTags[$variableName], $scope, $variableName, true, $narrowedByForeach);
}
$scope = $scope->assignVariable($variableName, $variableType, new MixedType(), TrinaryLogic::createYes());
}

if (count($variableNames) === 1 && count($varTags) === 1 && isset($varTags[0])) {
$variableType = $varTags[0]->getType();
$changed = true;
if ($this->reportRedundantVarTag) {
$this->redundantVarTagDetector->recordVariable($node, 0, $varTags[0], $scope, $variableNames[0], true, $narrowedByForeach);
}
$scope = $scope->assignVariable($variableNames[0], $variableType, new MixedType(), TrinaryLogic::createYes());
}

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

namespace PHPStan\Node;

use Override;
use PhpParser\Node\Stmt;
use PhpParser\NodeAbstract;
use PHPStan\PhpDoc\Tag\VarTag;

/**
* An inline @var tag above the statement that changed nothing about what it
* re-typed, emitted with reportRedundantVarTag once the statement is processed.
*/
final class RedundantVarTagNode extends NodeAbstract implements VirtualNode
{

public function __construct(private Stmt $stmt, private VarTag $varTag, private ?string $variableName)
{
parent::__construct($stmt->getAttributes());
}

public function getStmt(): Stmt
{
return $this->stmt;
}

public function getVarTag(): VarTag
{
return $this->varTag;
}

/** Null for a tag without a variable name. */
public function getVariableName(): ?string
{
return $this->variableName;
}

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

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

}
Loading
Loading