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
144 changes: 131 additions & 13 deletions src/Dependency/DependencyResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use PHPStan\Type\Type;
use function array_key_exists;
use function count;
use function get_class;
use function in_array;
use function is_file;
use function spl_object_id;
Expand All @@ -44,9 +45,74 @@
final class DependencyResolver
{

private const PROFILE_VAR_TAGS = 1;

private const PROFILE_CHAIN = 2;

private const PROFILE_EXPORT = 4;

private const PROFILE_NAME_SCOPE = 8;

/** Node classes the branch chain in collectNodeDependencies() reacts to */
private const CHAIN_NODE_TYPES = [
Node\Stmt\Class_::class,
Node\Stmt\Interface_::class,
Node\Stmt\Enum_::class,
InClassMethodNode::class,
InPropertyHookNode::class,
ClassPropertyNode::class,
InFunctionNode::class,
Closure::class,
Node\Expr\ArrowFunction::class,
Node\Expr\FuncCall::class,
Node\Expr\MethodCall::class,
Node\Expr\PropertyFetch::class,
Node\Expr\StaticCall::class,
Node\Expr\ClassConstFetch::class,
Node\Expr\ConstFetch::class,
Node\Expr\StaticPropertyFetch::class,
Node\Expr\New_::class,
Node\Stmt\Trait_::class,
Node\Stmt\TraitUse::class,
Node\Expr\Instanceof_::class,
Node\Expr\Include_::class,
Node\Stmt\Catch_::class,
ArrayDimFetch::class,
Foreach_::class,
Array_::class,
StaticMethodCallableNode::class,
MethodCallableNode::class,
FunctionCallableNode::class,
InstantiationCallableNode::class,
];

/**
* Node classes ExportedNodeResolver::resolve() reacts to. A class member is not among them: it is
* exported as part of the class declaring it, through exportClassStatement(), never on its own.
*/
private const EXPORT_NODE_TYPES = [
Node\Stmt\Class_::class,
Node\Stmt\Interface_::class,
Node\Stmt\Enum_::class,
Node\Stmt\Trait_::class,
Node\Stmt\Function_::class,
Node\Stmt\Const_::class,
Node\Expr\FuncCall::class,
];

/** Node classes ExportedNameScopeTracker::enterNode() reacts to */
private const NAME_SCOPE_NODE_TYPES = [
Node\Stmt\Namespace_::class,
Node\Stmt\Use_::class,
Node\Stmt\GroupUse::class,
];

/** @var array<string, array<int, ClassReflection|FunctionReflection|ConstantReflection>> reflections keyed by spl_object_id() */
private array $classDependencies = [];

/** @var array<class-string, int> */
private array $nodeProfiles = [];

private ExportedNameScopeTracker $nameScopeTracker;

private ?string $nameScopeFile = null;
Expand All @@ -72,27 +138,42 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
$this->nameScopeFile = $file;
$this->nameScopeTracker->reset();
}
$this->nameScopeTracker->enterNode($node);
$nodeClass = get_class($node);
$nodeProfile = $this->nodeProfiles[$nodeClass] ??= $this->resolveNodeProfile($node);

if (($nodeProfile & self::PROFILE_NAME_SCOPE) !== 0) {
$this->nameScopeTracker->enterNode($node);
}

// Keyed by spl_object_id(), so that a reflection collected again - every level of a class hierarchy
// repeats the interfaces it inherits, and the classes a node references share most of their
// ancestors - is kept only once instead of being resolved to its file and package once more.
$dependenciesReflections = [];
$dependenciesFilePaths = [];

if (
$node instanceof Node\Stmt
&& !$node instanceof VirtualNode
&& !$node instanceof Node\Stmt\ClassLike
&& !$node instanceof Node\Stmt\ClassMethod
&& !$node instanceof Node\Stmt\Function_
&& !$node instanceof Node\Stmt\Property
&& !$node instanceof Node\Stmt\ClassConst
&& !$node instanceof Node\Stmt\Const_
) {
if (($nodeProfile & self::PROFILE_VAR_TAGS) !== 0 && $node instanceof Node\Stmt) {
$this->extractStmtVarTags($node, $scope, $dependenciesReflections);
}

if (($nodeProfile & self::PROFILE_CHAIN) !== 0) {
$this->collectNodeDependencies($node, $scope, $dependenciesReflections, $dependenciesFilePaths);
}

$exportedNode = ($nodeProfile & self::PROFILE_EXPORT) !== 0
? $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope())
: null;

return new NodeDependencies($this->fileHelper, $dependenciesReflections, $exportedNode, $dependenciesFilePaths);
}

/**
* The node-kind branches. Only entered when resolveNodeProfile() says a branch can match.
*
* @param array<ClassReflection|FunctionReflection|ConstantReflection> $dependenciesReflections
* @param list<string> $dependenciesFilePaths
*/
private function collectNodeDependencies(Node $node, Scope $scope, array &$dependenciesReflections, array &$dependenciesFilePaths): void
{
if ($node instanceof Node\Stmt\Class_) {
if (isset($node->namespacedName)) {
$this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections);
Expand Down Expand Up @@ -562,8 +643,6 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
} elseif ($node instanceof InstantiationCallableNode) {
$dependenciesReflections += $this->resolveDependencies(new Node\Expr\New_($node->getClass()), $scope)->getReflections();
}

return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope()), $dependenciesFilePaths);
}

public function resolveUsedTraitDependencies(InClassNode $inClassNode): NodeDependencies
Expand Down Expand Up @@ -606,6 +685,45 @@ private function getClassNamesFromClassString(Type $type): array
return $classNames;
}

/**
* Which parts of resolveDependencies() a node of this class can reach. Depends only on the class,
* so it is computed once per class and reused for every node of it.
*/
private function resolveNodeProfile(Node $node): int
{
$profile = 0;
if (
$node instanceof Node\Stmt
&& !$node instanceof VirtualNode
&& !$node instanceof Node\Stmt\ClassLike
&& !$node instanceof Node\Stmt\ClassMethod
&& !$node instanceof Node\Stmt\Function_
&& !$node instanceof Node\Stmt\Property
&& !$node instanceof Node\Stmt\ClassConst
&& !$node instanceof Node\Stmt\Const_
) {
$profile |= self::PROFILE_VAR_TAGS;
}

$lists = [
self::PROFILE_CHAIN => self::CHAIN_NODE_TYPES,
self::PROFILE_EXPORT => self::EXPORT_NODE_TYPES,
self::PROFILE_NAME_SCOPE => self::NAME_SCOPE_NODE_TYPES,
];
foreach ($lists as $bit => $nodeTypes) {
foreach ($nodeTypes as $nodeType) {
if (!$node instanceof $nodeType) {
continue;
}

$profile |= $bit;
break;
}
}

return $profile;
}

/**
* Extracts the classes referenced from a variable-level var-tag PHPDoc attached to a statement.
*
Expand Down
115 changes: 115 additions & 0 deletions tests/PHPStan/Dependency/NodeProfileMirrorsBranchesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php declare(strict_types = 1);

namespace PHPStan\Dependency;

use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\ParserFactory;
use PHPStan\Testing\PHPStanTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
use function array_values;
use function file_get_contents;
use function sort;

/**
* DependencyResolver answers per node class which parts of resolveDependencies() a node can reach,
* from three lists of node classes. Each list mirrors a chain of instanceof checks somewhere else, and
* a branch added without an entry in its list would silently stop producing dependencies - which
* leaves a stale result cache rather than a failing test. So the lists are read back out of the source
* they mirror.
*/
final class NodeProfileMirrorsBranchesTest extends PHPStanTestCase
{

/**
* @return iterable<string, array{string, string, string}>
*/
public static function dataLists(): iterable
{
yield 'the branch chain' => [
'CHAIN_NODE_TYPES',
__DIR__ . '/../../../src/Dependency/DependencyResolver.php',
'collectNodeDependencies',
];
yield 'the exported nodes' => [
'EXPORT_NODE_TYPES',
__DIR__ . '/../../../src/Dependency/ExportedNodeResolver.php',
'resolve',
];
yield 'the PHPDoc name scope' => [
'NAME_SCOPE_NODE_TYPES',
__DIR__ . '/../../../src/Dependency/ExportedNameScopeTracker.php',
'enterNode',
];
}

#[DataProvider('dataLists')]
public function testListMirrorsTheBranches(string $constantName, string $file, string $methodName): void
{
$reflection = new ReflectionClass(DependencyResolver::class);
/** @var list<string> $listed */
$listed = $reflection->getConstant($constantName);
sort($listed);

$matched = $this->nodeClassesMatchedIn($file, $methodName);

$this->assertSame(
$listed,
$matched,
$constantName . ' does not match the node classes ' . $methodName . '() reacts to.',
);
}

/**
* Every class the method's `$node instanceof X` checks name, in source order, deduplicated.
*
* @return list<string>
*/
private function nodeClassesMatchedIn(string $file, string $methodName): array
{
$contents = file_get_contents($file);
$this->assertNotFalse($contents);

$parser = (new ParserFactory())->createForHostVersion();
$stmts = $parser->parse($contents);
$this->assertNotNull($stmts);

$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver());
$stmts = $traverser->traverse($stmts);

$method = null;
foreach ((new NodeFinder())->findInstanceOf($stmts, Node\Stmt\ClassMethod::class) as $classMethod) {
if ($classMethod->name->toString() !== $methodName) {
continue;
}

$method = $classMethod;
break;
}

$this->assertNotNull($method, $methodName . '() not found in ' . $file);

$classNames = [];
foreach ((new NodeFinder())->findInstanceOf([$method], Node\Expr\Instanceof_::class) as $instanceof) {
if (!$instanceof->expr instanceof Node\Expr\Variable || $instanceof->expr->name !== 'node') {
continue;
}
if (!$instanceof->class instanceof Node\Name) {
continue;
}

$className = $instanceof->class->toString();
$classNames[$className] = $className;
}

$classNames = array_values($classNames);
sort($classNames);

return $classNames;
}

}
Loading