Repository: qossmic/deptrac (php)

Issue:
Tracking custom function calls
Follow up to #634

Creating an AstDependency when a non-internal function is called
Cannot find reset function with functionName collector
Hi! Thank you for the library.

It seems that 

```yaml
        -   name: reset
            collectors:
                -   type: functionName
                    regex: ^reset$
```

cannot find a [reset](https://www.php.net/manual/ru/function.reset.php) function.

Am I doing smth wrong?

Pull request:
Make AST map aware of custom function calls
Fixes #637

Patch:
diff --git a/config/services.php b/config/services.php
index 860f7e0b..f1df8da2 100644
--- a/config/services.php
+++ b/config/services.php
@@ -39,6 +39,7 @@ use Qossmic\Deptrac\Dependency\DependencyResolver;
 use Qossmic\Deptrac\Dependency\Emitter\ClassDependencyEmitter;
 use Qossmic\Deptrac\Dependency\Emitter\ClassSuperglobalDependencyEmitter;
 use Qossmic\Deptrac\Dependency\Emitter\FileDependencyEmitter;
+use Qossmic\Deptrac\Dependency\Emitter\FunctionCallDependencyEmitter;
 use Qossmic\Deptrac\Dependency\Emitter\FunctionDependencyEmitter;
 use Qossmic\Deptrac\Dependency\Emitter\FunctionSuperglobalDependencyEmitter;
 use Qossmic\Deptrac\Dependency\Emitter\UsesDependencyEmitter;
@@ -175,6 +176,9 @@ return static function (ContainerConfigurator $container): void {
     $services
         ->set(FunctionDependencyEmitter::class)
         ->tag('dependency_emitter');
+    $services
+        ->set(FunctionCallDependencyEmitter::class)
+        ->tag('dependency_emitter');
     $services
         ->set(FunctionSuperglobalDependencyEmitter::class)
         ->tag('dependency_emitter');
diff --git a/docs/depfile.md b/docs/depfile.md
index 8cbd058f..8a440814 100644
--- a/docs/depfile.md
+++ b/docs/depfile.md
@@ -32,6 +32,7 @@ deptrac:
       - file
       - class_superglobal
       - function_superglobal
+      - function_call
 ```
 
 - `class` (default) - analyses class definitions for everything apart from superglobal usage.
@@ -40,6 +41,7 @@ deptrac:
 - `file` - analyses file for everything apart from use statements and function/class definitions.
 - `function` - analyses function definitions for everything apart from superglobal usage.
 - `function_superglobal` - analyses function definitions for superglobal usage.
+- `function_call` - analyses calls to custom(user-defined) functions
 
 ### `exclude_files`
 
diff --git a/src/Ast/AstMap/DependencyToken.php b/src/Ast/AstMap/DependencyToken.php
index db2be20b..89651011 100644
--- a/src/Ast/AstMap/DependencyToken.php
+++ b/src/Ast/AstMap/DependencyToken.php
@@ -25,6 +25,7 @@ class DependencyToken
     public const ANONYMOUS_CLASS_TRAIT = 'anonymous_class_trait';
     public const ATTRIBUTE = 'attribute';
     public const SUPERGLOBAL_VARIABLE = 'superglobal_variable';
+    public const UNRESOLVED_FUNCTION_CALL = 'unresolved_function_call';
 
     private TokenInterface $token;
     private FileOccurrence $fileOccurrence;
diff --git a/src/Ast/AstMap/ReferenceBuilder.php b/src/Ast/AstMap/ReferenceBuilder.php
index bacd3123..e9e97bb8 100644
--- a/src/Ast/AstMap/ReferenceBuilder.php
+++ b/src/Ast/AstMap/ReferenceBuilder.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Qossmic\Deptrac\Ast\AstMap;
 
 use Qossmic\Deptrac\Ast\AstMap\ClassLike\ClassLikeToken;
+use Qossmic\Deptrac\Ast\AstMap\FunctionLike\FunctionLikeToken;
 use Qossmic\Deptrac\Ast\AstMap\Variable\SuperGlobalToken;
 
 abstract class ReferenceBuilder
@@ -34,6 +35,24 @@ abstract class ReferenceBuilder
         return $this->tokenTemplates;
     }
 
+    /**
+     * Unqualified function and constant names inside a namespace cannot be
+     * statically resolved. Inside a namespace Foo, a call to strlen() may
+     * either refer to the namespaced \Foo\strlen(), or the global \strlen().
+     * Because PHP-Parser does not have the necessary context to decide this,
+     * such names are left unresolved.
+     */
+    public function unresolvedFunctionCall(string $functionName, int $occursAtLine): self
+    {
+        $this->dependencies[] = DependencyToken::fromType(
+            FunctionLikeToken::fromFQCN($functionName),
+            FileOccurrence::fromFilepath($this->filepath, $occursAtLine),
+            DependencyToken::UNRESOLVED_FUNCTION_CALL
+        );
+
+        return $this;
+    }
+
     public function variable(string $classLikeName, int $occursAtLine): self
     {
         $this->dependencies[] = DependencyToken::fromType(
diff --git a/src/Ast/Parser/NikicPhpParser/FileReferenceVisitor.php b/src/Ast/Parser/NikicPhpParser/FileReferenceVisitor.php
index 04d357c4..a5b9ed55 100644
--- a/src/Ast/Parser/NikicPhpParser/FileReferenceVisitor.php
+++ b/src/Ast/Parser/NikicPhpParser/FileReferenceVisitor.php
@@ -159,6 +159,12 @@ class FileReferenceVisitor extends NodeVisitorAbstract
         }
 
         // Resolve code
+        if ($node instanceof Node\Expr\FuncCall) {
+            foreach ($this->typeResolver->resolvePHPParserTypes($this->currentTypeScope, $node->name) as $functionName) {
+                $this->currentReference->unresolvedFunctionCall($functionName, $node->getLine());
+            }
+        }
+
         if ($node instanceof Node\Stmt\TraitUse && $this->currentReference instanceof ClassLikeReferenceBuilder) {
             foreach ($this->typeResolver->resolvePHPParserTypes($this->currentTypeScope, ...$node->traits) as $classLikeName) {
                 $this->currentReference->trait($classLikeName, $node->getLine());
diff --git a/src/Dependency/Emitter/ClassDependencyEmitter.php b/src/Dependency/Emitter/ClassDependencyEmitter.php
index 53c76f4c..e8845bfe 100644
--- a/src/Dependency/Emitter/ClassDependencyEmitter.php
+++ b/src/Dependency/Emitter/ClassDependencyEmitter.php
@@ -30,6 +30,9 @@ final class ClassDependencyEmitter implements DependencyEmitterInterface
                 if (DependencyToken::SUPERGLOBAL_VARIABLE === $dependency->getType()) {
                     continue;
                 }
+                if (DependencyToken::UNRESOLVED_FUNCTION_CALL === $dependency->getType()) {
+                    continue;
+                }
 
                 $dependencyList->addDependency(
                     new Dependency(
diff --git a/src/Dependency/Emitter/EmitterTypes.php b/src/Dependency/Emitter/EmitterTypes.php
index cfa3a53c..c03b7100 100644
--- a/src/Dependency/Emitter/EmitterTypes.php
+++ b/src/Dependency/Emitter/EmitterTypes.php
@@ -10,6 +10,7 @@ final class EmitterTypes
     public const CLASS_SUPERGLOBAL_TOKEN = 'class_superglobal';
     public const FILE_TOKEN = 'file';
     public const FUNCTION_TOKEN = 'function';
+    public const FUNCTION_CALL = 'function_call';
     public const FUNCTION_SUPERGLOBAL_TOKEN = 'function_superglobal';
     public const USE_TOKEN = 'use';
 }
diff --git a/src/Dependency/Emitter/FileDependencyEmitter.php b/src/Dependency/Emitter/FileDependencyEmitter.php
index 55e2ed7d..bbfa88e1 100644
--- a/src/Dependency/Emitter/FileDependencyEmitter.php
+++ b/src/Dependency/Emitter/FileDependencyEmitter.php
@@ -25,15 +25,20 @@ final class FileDependencyEmitter implements DependencyEmitterInterface
     {
         foreach ($astMap->getFileReferences() as $fileReference) {
             $dependencies = $fileReference->getDependencies();
-            foreach ($dependencies as $emittedDependency) {
-                if (DependencyToken::USE === $emittedDependency->getType()) {
+            foreach ($dependencies as $dependency) {
+                if (DependencyToken::USE === $dependency->getType()) {
                     continue;
                 }
+
+                if (DependencyToken::UNRESOLVED_FUNCTION_CALL === $dependency->getType()) {
+                    continue;
+                }
+
                 $dependencyList->addDependency(
                     new Dependency(
                         $fileReference->getToken(),
-                        $emittedDependency->getToken(),
-                        $emittedDependency->getFileOccurrence()
+                        $dependency->getToken(),
+                        $dependency->getFileOccurrence()
                     )
                 );
             }
diff --git a/src/Dependency/Emitter/FunctionCallDependencyEmitter.php b/src/Dependency/Emitter/FunctionCallDependencyEmitter.php
new file mode 100644
index 00000000..489c16a8
--- /dev/null
+++ b/src/Dependency/Emitter/FunctionCallDependencyEmitter.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Qossmic\Deptrac\Dependency\Emitter;
+
+use Qossmic\Deptrac\Ast\AstMap\AstMap;
+use Qossmic\Deptrac\Ast\AstMap\ClassLike\ClassLikeReference;
+use Qossmic\Deptrac\Ast\AstMap\DependencyToken;
+use Qossmic\Deptrac\Ast\AstMap\File\FileReference;
+use Qossmic\Deptrac\Ast\AstMap\FunctionLike\FunctionLikeReference;
+use Qossmic\Deptrac\Ast\AstMap\FunctionLike\FunctionLikeToken;
+use Qossmic\Deptrac\Dependency\Dependency;
+use Qossmic\Deptrac\Dependency\DependencyList;
+
+final class FunctionCallDependencyEmitter implements DependencyEmitterInterface
+{
+    public static function getAlias(): string
+    {
+        return EmitterTypes::FUNCTION_CALL;
+    }
+
+    public function getName(): string
+    {
+        return 'FunctionCallDependencyEmitter';
+    }
+
+    public function applyDependencies(AstMap $astMap, DependencyList $dependencyList): void
+    {
+        $this->createDependenciesForReferences($astMap->getClassLikeReferences(), $astMap, $dependencyList);
+        $this->createDependenciesForReferences($astMap->getFunctionLikeReferences(), $astMap, $dependencyList);
+        $this->createDependenciesForReferences($astMap->getFileReferences(), $astMap, $dependencyList);
+    }
+
+    /**
+     * @param array<FunctionLikeReference|ClassLikeReference|FileReference> $references
+     */
+    private function createDependenciesForReferences(array $references, AstMap $astMap, DependencyList $dependencyList): void
+    {
+        foreach ($references as $reference) {
+            foreach ($reference->getDependencies() as $dependency) {
+                if (DependencyToken::UNRESOLVED_FUNCTION_CALL !== $dependency->getType()) {
+                    continue;
+                }
+
+                $token = $dependency->getToken();
+                assert($token instanceof FunctionLikeToken);
+                if (null === $astMap->getFunctionReferenceForToken($token)) {
+                    continue;
+                }
+
+                $dependencyList->addDependency(
+                    new Dependency(
+                        $reference->getToken(), $dependency->getToken(), $dependency->getFileOccurrence()
+                    )
+                );
+            }
+        }
+    }
+}
diff --git a/src/Dependency/Emitter/FunctionDependencyEmitter.php b/src/Dependency/Emitter/FunctionDependencyEmitter.php
index 8586d86e..c8d0f9e9 100644
--- a/src/Dependency/Emitter/FunctionDependencyEmitter.php
+++ b/src/Dependency/Emitter/FunctionDependencyEmitter.php
@@ -29,6 +29,11 @@ final class FunctionDependencyEmitter implements DependencyEmitterInterface
                     if (DependencyToken::SUPERGLOBAL_VARIABLE === $dependency->getType()) {
                         continue;
                     }
+
+                    if (DependencyToken::UNRESOLVED_FUNCTION_CALL === $dependency->getType()) {
+                        continue;
+                    }
+
                     $dependencyList->addDependency(
                         new Dependency(
                             $astFunctionReference->getToken(),
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index a6367ae7..1850d75a 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -145,6 +145,7 @@ class Configuration implements ConfigurationInterface
             EmitterTypes::CLASS_SUPERGLOBAL_TOKEN,
             EmitterTypes::FILE_TOKEN,
             EmitterTypes::FUNCTION_TOKEN,
+            EmitterTypes::FUNCTION_CALL,
             EmitterTypes::FUNCTION_SUPERGLOBAL_TOKEN,
             EmitterTypes::USE_TOKEN,
         ];


Test patch:
diff --git a/tests/Dependency/Emitter/Fixtures/Bar.php b/tests/Dependency/Emitter/Fixtures/Bar.php
index 2ef6fa9f..f3000a21 100644
--- a/tests/Dependency/Emitter/Fixtures/Bar.php
+++ b/tests/Dependency/Emitter/Fixtures/Bar.php
@@ -82,4 +82,6 @@ function testAnonymousClass() {
             $post = $_POST;
         }
     };
+
+    test(null, null);
 }
diff --git a/tests/Dependency/Emitter/Fixtures/Baz.php b/tests/Dependency/Emitter/Fixtures/Baz.php
index 83554304..fc1008d2 100644
--- a/tests/Dependency/Emitter/Fixtures/Baz.php
+++ b/tests/Dependency/Emitter/Fixtures/Baz.php
@@ -10,7 +10,7 @@ new \SomeOtherClass();
 $foo = function (SomeOtherParam $someOtherParam) {
 };
 
-assert($foo instanceof SomeInstanceOf);
+\assert($foo instanceof SomeInstanceOf);
 
 SomeClass::staticMethodCall();
 
@@ -57,7 +57,7 @@ new class extends BarExtends implements BarInterface1, \BarInterface2 {
         $foo = function (SomeOtherParam $someOtherParam) {
         };
 
-        assert($foo instanceof SomeInstanceOf);
+        \assert($foo instanceof SomeInstanceOf);
 
         SomeClass::staticMethodCall();
 
diff --git a/tests/Dependency/Emitter/FunctionCallDependencyEmitterTest.php b/tests/Dependency/Emitter/FunctionCallDependencyEmitterTest.php
new file mode 100644
index 00000000..e2ef6149
--- /dev/null
+++ b/tests/Dependency/Emitter/FunctionCallDependencyEmitterTest.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Qossmic\Deptrac\Dependency\Emitter;
+
+use PHPUnit\Framework\TestCase;
+use Qossmic\Deptrac\Dependency\Emitter\FunctionCallDependencyEmitter;
+
+final class FunctionCallDependencyEmitterTest extends TestCase
+{
+    use EmitterTrait;
+
+    public function testGetName(): void
+    {
+        self::assertSame('FunctionCallDependencyEmitter', (new FunctionCallDependencyEmitter())->getName());
+    }
+
+    public function testApplyDependencies(): void
+    {
+        $deps = $this->getEmittedDependencies(
+            new FunctionCallDependencyEmitter(),
+            __DIR__.'/Fixtures/Bar.php'
+        );
+
+        self::assertCount(1, $deps);
+
+        self::assertContains('Foo\testAnonymousClass():86 on Foo\test()', $deps);
+    }
+}