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
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ parameters:
sortWithoutEffect: true
unresolvedTemplateArguments: true
closureSignaturesFromUsages: true
staticVariablesFromUsages: true
unusedVariable: true
unusedParameters: true
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ parameters:
sortWithoutEffect: false
unresolvedTemplateArguments: false
closureSignaturesFromUsages: false
staticVariablesFromUsages: false
unusedVariable: false
unusedParameters: false
fileExtensions:
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ parametersSchema:
sortWithoutEffect: bool()
unresolvedTemplateArguments: bool()
closureSignaturesFromUsages: bool()
staticVariablesFromUsages: bool()
unusedVariable: bool()
unusedParameters: bool()
])
Expand Down
8 changes: 0 additions & 8 deletions issue-bot/playground.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,3 @@ rules:
- PHPStan\Rules\Playground\NotAnalysedTraitRule
- PHPStan\Rules\Playground\NoPhpCodeRule
- PHPStan\Rules\Playground\PhpdocCommentRule

conditionalTags:
PHPStan\Rules\Playground\StaticVarWithoutTypeRule:
phpstan.rules.rule: %checkImplicitMixed%

services:
-
class: PHPStan\Rules\Playground\StaticVarWithoutTypeRule
223 changes: 223 additions & 0 deletions src/Analyser/Generics/StaticVariableInference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\Generics;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PHPStan\Analyser\MutatingScope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Turbo\ShadowedByTurboExtension;
use PHPStan\Type\Type;
use function array_pop;
use function count;
use function in_array;
use function is_array;
use function is_string;
use function preg_match;

/**
* Types a `static $x` variable of a function-like body from what the body does
* with it instead of `mixed`: every call starts where a previous one left the
* variable, so its type at the `static` statement is the default joined with
* every type the variable takes in the body - at its end, at every return,
* throw and yield, and at every call that could run the function again.
*
* The two-pass driver (StatementsHandler::processBodyStmtNodesTwoPass())
* walks the body with the default, collects the types the variable took, and
* walks the statements that read it again until the type at the `static`
* statement converges - before the template arguments and the closure
* signatures of the body are resolved, so they observe the converged type.
*
* A body whose variables can change behind the analysis' back (`$$name`,
* extract(), include, eval), a generator (a suspended call keeps the
* variable in any state), and a variable typed by `@var` or taken by reference
* keep today's behaviour.
*/
#[AutowiredService]
#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../../turbo-ext/src/StaticVariableInference.cpp')]
final class StaticVariableInference
{

private const SITES_ATTRIBUTE = 'staticVariableInferenceSites';

public function __construct(
#[AutowiredParameter(ref: '%featureToggles.staticVariablesFromUsages%')]
private bool $enabled,
)
{
}

/**
* The `static` variables of the body whose type is inferred, with the index
* of the top-level statement holding each and the variable's name.
*
* @param Node\Stmt[] $stmts
* @return list<array{Expr\Variable, int, string}>
*/
public function getSites(Node $functionLike, array $stmts): array
{
if (!$this->enabled) {
return [];
}

/** @var list<array{Expr\Variable, int, string}>|null $cached */
$cached = $functionLike->getAttribute(self::SITES_ATTRIBUTE);
if ($cached !== null) {
return $cached;
}

$sites = self::scanSites($stmts);
$functionLike->setAttribute(self::SITES_ATTRIBUTE, $sites);

return $sites;
}

/** Whether the current walk infers the type of the `static` variable. */
public function isInferred(MutatingScope $scope, Expr\Variable $var): bool
{
$frame = $scope->getCurrentTemplateArgumentFrame();
if ($frame === null) {
return false;
}
$body = $frame->getClosureSignatureBody();
if ($body === null) {
return false;
}
foreach ($this->getSites($body, $frame->getClosureSignatureStmts()) as [$site]) {
if ($site === $var) {
return true;
}
}

return false;
}

/**
* The [phpdoc, native] types the driver resolved for the `static` variable,
* null while observing.
*
* @return array{Type, Type}|null
*/
public function getResolvedTypes(MutatingScope $scope, Expr\Variable $var): ?array
{
$frame = $scope->getCurrentTemplateArgumentFrame();

return $frame !== null ? $frame->getStaticVariableTypes($var) : null;
}

/**
* @param Node\Stmt[] $stmts
* @return list<array{Expr\Variable, int, string}>
*/
private static function scanSites(array $stmts): array
{
/** @var list<array{Expr\Variable, int}> $candidates */
$candidates = [];
$excludedNames = [];
foreach ($stmts as $index => $stmt) {
$stack = [$stmt];
while (count($stack) > 0) {
$node = array_pop($stack);
if ($node instanceof Node\FunctionLike || $node instanceof Node\Stmt\ClassLike) {
continue;
}
if (
$node instanceof Expr\Yield_
|| $node instanceof Expr\YieldFrom
|| $node instanceof Expr\Include_
|| $node instanceof Expr\Eval_
) {
return [];
}
if ($node instanceof Expr\Variable && !is_string($node->name)) {
return [];
}
if (
$node instanceof Expr\FuncCall
&& $node->name instanceof Node\Name
&& in_array($node->name->toLowerString(), ['extract', 'parse_str'], true)
) {
return [];
}
if ($node instanceof Expr\AssignRef) {
self::collectRootNames($node->var, $excludedNames);
self::collectRootNames($node->expr, $excludedNames);
}
if ($node instanceof Node\Stmt\Global_) {
foreach ($node->vars as $var) {
self::collectRootNames($var, $excludedNames);
}
}
if ($node instanceof Node\Stmt\Static_ && !self::hasVarTag($node)) {
foreach ($node->vars as $var) {
$candidates[] = [$var->var, $index];
}
}
foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->$subNodeName;
if ($subNode instanceof Node) {
$stack[] = $subNode;
} elseif (is_array($subNode)) {
foreach ($subNode as $item) {
if (!$item instanceof Node) {
continue;
}
$stack[] = $item;
}
}
}
}
}

$sites = [];
foreach ($candidates as [$var, $index]) {
if (!is_string($var->name) || isset($excludedNames[$var->name])) {
continue;
}
$sites[] = [$var, $index, $var->name];
}

return $sites;
}

/**
* @param array<string, true> $names
*/
private static function collectRootNames(Expr $expr, array &$names): void
{
while (
$expr instanceof Expr\ArrayDimFetch
|| $expr instanceof Expr\PropertyFetch
|| $expr instanceof Expr\NullsafePropertyFetch
|| $expr instanceof Expr\StaticPropertyFetch
) {
if ($expr instanceof Expr\StaticPropertyFetch) {
return;
}
$expr = $expr->var;
}
if ($expr instanceof Expr\Variable && is_string($expr->name)) {
$names[$expr->name] = true;
return;
}
if (!$expr instanceof Expr\List_ && !$expr instanceof Expr\Array_) {
return;
}

foreach ($expr->items as $item) {
if ($item === null) {
continue;
}
self::collectRootNames($item->value, $names);
}
}

private static function hasVarTag(Node\Stmt\Static_ $stmt): bool
{
$docComment = $stmt->getDocComment();

return $docComment !== null && preg_match('~@(?:phpstan-|psalm-)?var\s~', $docComment->getText()) === 1;
}

}
38 changes: 38 additions & 0 deletions src/Analyser/Generics/TemplateArgumentFrame.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public static function returnTypeOfCall(ParametersAcceptor $acceptor, MutatingSc
* @param array<int, true> $settledClosureSites spl_object_id() of the closure nodes
* @param bool $observingClosures the template arguments are resolved, the closure signatures observed again
* @param array<int, array{Expr\Closure, int, bool}> $byRefSites spl_object_id() of the closure node => the node, its statement index, whether every invocation was seen
* @param array<int, array{Expr\Variable, Type, Type}> $staticVariableTypes spl_object_id() of a `static` variable node => the node, its phpdoc and native type - see StaticVariableInference
*/
public function __construct(
private readonly ?self $parent,
Expand All @@ -75,10 +76,46 @@ public function __construct(
private readonly array $settledClosureSites = [],
private readonly bool $observingClosures = false,
private readonly array $byRefSites = [],
private readonly array $staticVariableTypes = [],
)
{
}

/**
* @return array{Type, Type}|null
*/
public function getStaticVariableTypes(Expr\Variable $var): ?array
{
$types = $this->staticVariableTypes[spl_object_id($var)] ?? null;
if ($types === null || $types[0] !== $var) {
return null;
}

return [$types[1], $types[2]];
}

/**
* The frame with the inferred types of the body's `static` variables; the
* statements holding them are walked again in the second pass.
*
* @param array<int, array{Expr\Variable, Type, Type}> $staticVariableTypes
* @param array<int, true> $statementIndexes
*/
public function withStaticVariableTypes(array $staticVariableTypes, array $statementIndexes): self
{
return new self(
$this->parent,
$this->resolutions,
$this->siteStatementIndexes + $statementIndexes,
$this->closureSignatureBody,
$this->closureSignatureStmts,
$this->settledClosureSites,
$this->observingClosures,
$this->byRefSites,
$staticVariableTypes,
);
}

/**
* How the second pass treats the by-ref uses of a closure created in this
* frame's body - see ClosureSignatureInference::getByRefSiteMode().
Expand Down Expand Up @@ -133,6 +170,7 @@ public function withObservedClosures(array $closureResolutions, array $closureSi
$this->closureSignatureStmts,
$settledClosureSites,
byRefSites: $byRefSites,
staticVariableTypes: $this->staticVariableTypes,
);
}

Expand Down
Loading
Loading