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
97 changes: 90 additions & 7 deletions src/Analyser/ClassCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Interface_;
use PhpParser\Node\Stmt\While_;
Expand All @@ -37,9 +38,11 @@
use function array_merge;
use function array_unique;
use function count;
use function get_defined_functions;
use function implode;
use function in_array;
use function is_string;
use function strtolower;

final class ClassCollector extends NodeVisitorAbstract
{
Expand All @@ -62,15 +65,23 @@ final class ClassCollector extends NodeVisitorAbstract
/** @var string[] */
private array $fileUses = [];

/** @var ClassLike[] */
private array $fileClassLikes = [];

/** @var string[] */
private array $fileFunctions = [];

public function __construct(
private readonly LayerResolverInterface $layerResolver
) {
}

public function setCurrentFile(string $file): void
{
$this->currentFile = $file;
$this->fileUses = [];
$this->currentFile = $file;
$this->fileUses = [];
$this->fileClassLikes = [];
$this->fileFunctions = [];
}

/** @return ClassNode[] */
Expand All @@ -83,9 +94,17 @@ public function enterNode(Node $node): null
{
if ($node instanceof UseItem) {
$this->fileUses[] = implode('\\', $node->name->getParts());
return null;
}

if ($node instanceof Function_ && isset($node->namespacedName)) {
$this->fileFunctions[] = implode('\\', $node->namespacedName->getParts());
}

return null;
}

public function leaveNode(Node $node): null
{
if (! $node instanceof ClassLike) {
return null;
}
Expand All @@ -94,6 +113,25 @@ public function enterNode(Node $node): null
return null;
}

$this->fileClassLikes[] = $node;

return null;
}

/** @param Node[] $nodes */
public function afterTraverse(array $nodes): null
{
foreach ($this->fileClassLikes as $fileClassLike) {
$this->collectClassLike($fileClassLike);
}

$this->fileClassLikes = [];

return null;
}

private function collectClassLike(ClassLike $node): void
{
$className = $this->resolveClassName($node);
$layer = $this->layerResolver->resolve($className, $this->currentFile);
$dependencies = $this->collectDependencies($node);
Expand All @@ -120,8 +158,6 @@ className: $className,
functionCalls: $functionCalls,
superglobals: $superglobals,
);

return null;
}

private function resolveClassName(ClassLike $node): string
Expand Down Expand Up @@ -258,21 +294,53 @@ public function enterNode(Node $node): null
private function collectFunctionCalls(ClassLike $node): array
{
$nodeTraverser = new NodeTraverser();
$visitor = new class extends NodeVisitorAbstract {
$visitor = new class ($this->fileFunctions, $this->internalFunctions()) extends NodeVisitorAbstract {
/** @var string[] */
public array $calls = [];

/**
* @param string[] $fileFunctions
* @param string[] $internalFunctions
*/
public function __construct(
private readonly array $fileFunctions,
private readonly array $internalFunctions,
) {
}

public function enterNode(Node $node): null
{
if (
$node instanceof FuncCall
&& $node->name instanceof Name
) {
$this->calls[] = (string) $node->name;
$this->calls[] = $this->resolveFunctionName($node->name);
}

return null;
}

private function resolveFunctionName(Name $name): string
{
if ($name instanceof FullyQualified) {
return implode('\\', $name->getParts());
}

$functionName = implode('\\', $name->getParts());
if (in_array(strtolower($functionName), $this->internalFunctions, true)) {
return $functionName;
}

$namespacedName = $name->getAttribute('namespacedName');
if (
$namespacedName instanceof Name
&& in_array(implode('\\', $namespacedName->getParts()), $this->fileFunctions, true)
) {
return implode('\\', $namespacedName->getParts());
}

return $functionName;
}
};

$nodeTraverser->addVisitor($visitor);
Expand All @@ -281,6 +349,21 @@ public function enterNode(Node $node): null
return array_unique($visitor->calls);
}

/**
* @return string[]
*/
private function internalFunctions(): array
{
$functions = get_defined_functions();
$internal = [];

foreach ($functions['internal'] as $function) {
$internal[] = strtolower($function);
}

return $internal;
}

/**
* @return string[]
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Analyser/ClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* @param string[] $dependencies Fully-qualified class names this class depends on
* @param string[] $implements Interface names this class implements
* @param MethodNode[] $methods Public methods of this class
* @param string[] $functionCalls Global functions called within this class
* @param string[] $functionCalls Functions called within this class
* @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.)
*/
public function __construct(
Expand Down
60 changes: 60 additions & 0 deletions tests/Analyser/ClassCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,66 @@ public function testCollectsFunctionCalls(): void
$this->assertContains('var_dump', $classNode->functionCalls);
}

public function testCollectsImportedFunctionCalls(): void
{
$code = <<<'PHP'
<?php
namespace App\Support;

use function Vendor\debug;

class Foo {
public function bar(): void {
debug("x");
}
}
PHP;
$classNode = $this->collect($code, resolveNames: true);

$this->assertContains('Vendor\debug', $classNode->functionCalls);
}

public function testKeepsNativeFunctionCallsUnqualifiedInsideNamespace(): void
{
$classNode = $this->collect(
'<?php namespace App\Support; class Foo { public function bar(): int { return strlen("x"); } }',
resolveNames: true
);

$this->assertContains('strlen', $classNode->functionCalls);
$this->assertNotContains('App\Support\strlen', $classNode->functionCalls);
}

public function testCollectsDeclaredNamespacedFunctionCalls(): void
{
$code = <<<'PHP'
<?php
namespace App\Support;

class Foo {
public function bar(): void {
debug("x");
}
}

function debug(string $value): void {}
PHP;
$classNode = $this->collect($code, resolveNames: true);

$this->assertContains('App\Support\debug', $classNode->functionCalls);
}

public function testKeepsUnresolvedFunctionCallsAsWrittenInsideNamespace(): void
{
$classNode = $this->collect(
'<?php namespace App\Support; class Foo { public function bar(): void { missing_function("x"); } }',
resolveNames: true
);

$this->assertContains('missing_function', $classNode->functionCalls);
$this->assertNotContains('App\Support\missing_function', $classNode->functionCalls);
}

public function testCollectsSuperglobals(): void
{
$classNode = $this->collect('<?php class Foo { public function bar(): void { $x = $_GET["id"]; } }');
Expand Down
Loading