diff --git a/.build/.gitignore b/.build/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.build/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..760b8b8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset=utf-8 +end_of_line=lf +trim_trailing_whitespace=true +insert_final_newline=true +indent_style=space +indent_size=4 + +[Makefile] +indent_style=tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb25ee9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +composer.lock + +*.swp +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3501102 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +The MIT License (MIT) + +Copyright (c) 2019 Gert de Pagter + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eed74e7 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# PHP Depend resolver + +## What is this + +This library allows you to find the dependencies of a php file. + +It was created to help with another libary, that allows you to run tests for only changes files (and their dependencies) + +## Usage +```php +build(); + +$deps = $finder->getAllFilesDependingOn('./tests/Fixtures/Circular/A.php'); + +foreach ($deps as $dep) { + var_dump($dep); +} + +$finder->reBuild(['./src/Domain/User.php', './tests/Domain/User.php', './src/functions.php']); +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e545fe7 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "backendtea/dependency-finder", + "description": "Find out exactly what classes depend on what other classes", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + } + ], + "require": { + "php": "^7.2", + "nikic/php-parser": "^4.2", + "symfony/finder": "^4.3" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "infection/infection": "^0.13.3", + "phpstan/phpstan": "^0.11", + "phpunit/phpunit": "^8.2" + }, + "config": { + "preferred-install": "dist", + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Depend\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Depend\\Test\\": "tests/" + } + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..fab3a37 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,14 @@ +{ + "timeout": 5, + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": ".build/infection.log" + }, + "mutators": { + "@default": true + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..422dceb --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,18 @@ + + + + + + + + + + + + + src + tests + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ff0cc44 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + - vendor/phpstan/phpstan/conf/config.levelmax.neon + +parameters: + paths: + - src + - tests + tmpDir: %currentWorkingDirectory%/.build diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b1dde1b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + ./tests/ + ./tests/Fixtures + + + + + + ./src/ + + + diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php new file mode 100644 index 0000000..26199f1 --- /dev/null +++ b/src/Dependency/DependencyResolver.php @@ -0,0 +1,67 @@ + */ + private $resolved = []; + + /** @var array */ + private $knownDependencies = []; + + /** + * Key: Class/ function name + * Value: Files that have a dependency on the class. + * + * @var array> $dependantMap + */ + private $dependantMap = []; + + /** + * Key: File name + * Value: Classes it declares + * + * @var array> $declareMap + */ + private $declareMap = []; + + /** + * @param array> $declareMap + * @param array> $dependantMap + * + * @return array + */ + public function resolve(string $fileName, array $declareMap, array $dependantMap) : array + { + $this->declareMap = $declareMap; + $this->dependantMap = $dependantMap; + foreach ($this->declareMap[$fileName]?? [] as $class) { + $this->resolveClass($class); + } + + return $this->knownDependencies; + } + + private function resolveClass(string $className) : void + { + if (array_key_exists($className, $this->resolved)) { + return; + } + $this->resolved[$className] = true; + + $dependants = $this->dependantMap['\\' . $className] ?? []; + + array_push($this->knownDependencies, ...$dependants); + foreach ($dependants as $dependant) { + foreach ($this->declareMap[$dependant]?? [] as $class) { + $this->resolveClass($class); + } + } + } +} diff --git a/src/DependencyFinder.php b/src/DependencyFinder.php new file mode 100644 index 0000000..41b1221 --- /dev/null +++ b/src/DependencyFinder.php @@ -0,0 +1,137 @@ +> $dependencyMap + */ + private $dependencyMap = []; + + /** + * Key: Class/ function name + * Value: Files that have a dependency on the class. + * + * @var array> $dependantMap + */ + private $dependantMap = []; + + /** + * Key: File name + * Value: Classes it declares + * + * @var array> $declareMap + */ + private $declareMap = []; + + /** @var Parser */ + private $parser; + + /** + * List of directories to build dependencies from. + * for example: `new DependencyFinder(['./src', './tests', './lib']);` + * + * @param array|string[] $directories + * @param array|string[] $exclude + */ + public function __construct(array $directories, array $exclude = []) + { + $this->directories = $directories; + $this->exclude = $exclude; + $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + } + + public function build() : void + { + /** @var SplFileInfo $file */ + foreach (Finder::create() + ->in($this->directories) + ->exclude($this->exclude) + ->files() as $file) { + $path = $file->getRealPath(); + if ($path === false) { + $path = ''; + } + + $this->reBuildFor($path); + } + } + + /** + * @return array + */ + public function getAllFilesDependingOn(string $fileName) : array + { + $path = realpath($fileName); + if ($path === false) { + return []; + } + + return(new DependencyResolver())->resolve($path, $this->declareMap, $this->dependantMap); + } + + /** + * @param array|string[] $fileNames + */ + public function reBuild(array $fileNames) : void + { + foreach ($fileNames as $fileName) { + $this->reBuildFor($fileName); + } + } + + private function reBuildFor(string $fileName) : void + { + $info = $this->traversePath($fileName); + $this->dependencyMap[$fileName] = array_unique($info->dependencies); + $this->declareMap[$fileName] = $info->declares; + foreach ($info->dependencies as $dependency) { + $this->dependantMap[$dependency][] = $fileName; + } + } + + private function traversePath(string $filePath) : File + { + $content = file_get_contents($filePath); + if ($content === false) { + return new File(); + } + + $nodes = $this->parser->parse($content); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $traverser->addVisitor(new ParentConnectorVisitor()); + $nameCollector = new NameCollector(); + $declareCollector = new DeclarationCollector(); + $traverser->addVisitor($nameCollector); + $traverser->addVisitor($declareCollector); + $traverser->traverse($nodes); + + return new File($declareCollector->declared, $nameCollector->resolvedNames); + } +} diff --git a/src/File.php b/src/File.php new file mode 100644 index 0000000..f38bfa3 --- /dev/null +++ b/src/File.php @@ -0,0 +1,32 @@ + + */ + public $declares = []; + + /** + * Array of all the class/function dependencies of this file + * + * @var array + */ + public $dependencies = []; + + /** + * @param array $delcares + * @param array $dependencies + */ + public function __construct(array $delcares = [], array $dependencies = []) + { + $this->declares = $delcares; + $this->dependencies = $dependencies; + } +} diff --git a/src/PHPParser/Visitor/DeclarationCollector.php b/src/PHPParser/Visitor/DeclarationCollector.php new file mode 100644 index 0000000..96c1ba1 --- /dev/null +++ b/src/PHPParser/Visitor/DeclarationCollector.php @@ -0,0 +1,37 @@ + */ + public $declared = []; + + /** + * @param array $nodes + * + * @return array + */ + public function beforeTraverse(array $nodes) : ?array + { + $this->declared = []; + + return null; + } + + public function enterNode(Node $node) : ?Node + { + /** @var Node\Name|null $name */ + $name = $node->namespacedName ?? null; + if ($name instanceof Node\Name) { + $this->declared[] = $name->toCodeString(); + } + + return null; + } +} diff --git a/src/PHPParser/Visitor/NameCollector.php b/src/PHPParser/Visitor/NameCollector.php new file mode 100644 index 0000000..9b0761e --- /dev/null +++ b/src/PHPParser/Visitor/NameCollector.php @@ -0,0 +1,50 @@ + */ + public $resolvedNames = []; + + /** + * @param array|Node[] $nodes + * + * @return array|Node[]|null + */ + public function beforeTraverse(array $nodes) : ?array + { + $this->resolvedNames = []; + + return null; + } + + /** + * @return int|Node|null + */ + public function enterNode(Node $node) + { + if ($node instanceof Node\Stmt\Use_) { + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + if ($node instanceof Node\Name) { + $parent = $node->getAttribute(ParentConnectorVisitor::PARENT_KEY); + if ($parent instanceof Node\Stmt\Namespace_ + || $parent instanceof Node\Expr\ConstFetch + || $parent instanceof Node\Expr\ClassConstFetch + ) { + return null; + } + + $this->resolvedNames[] = $node->toCodeString(); + } + + return null; + } +} diff --git a/src/PHPParser/Visitor/ParentConnectorVisitor.php b/src/PHPParser/Visitor/ParentConnectorVisitor.php new file mode 100644 index 0000000..ca872d4 --- /dev/null +++ b/src/PHPParser/Visitor/ParentConnectorVisitor.php @@ -0,0 +1,82 @@ + */ + private $stack = []; + + /** + * @param array $nodes + * + * @return array|null + */ + public function beforeTraverse(array $nodes) : ?array + { + $this->stack = []; + + return null; + } + + public function enterNode(Node $node) : ?Node + { + if (! empty($this->stack)) { + $node->setAttribute(self::PARENT_KEY, $this->stack[count($this->stack) - 1]); + } + + $this->stack[] = $node; + + return null; + } + + public function leaveNode(Node $node) : ?Node + { + array_pop($this->stack); + + return null; + } +} diff --git a/tests/DependencyFinderTest.php b/tests/DependencyFinderTest.php new file mode 100644 index 0000000..9571d53 --- /dev/null +++ b/tests/DependencyFinderTest.php @@ -0,0 +1,30 @@ +build(); + $deps = $finder->getAllFilesDependingOn(__DIR__ . '/Fixtures/Circular/A.php'); + + $this->assertCount(2, $deps); + } + + public function testAWrongFileReturnsNoDependencies() : void + { + $finder = new DependencyFinder([__DIR__ . '/Fixtures/Circular']); + $finder->build(); + + $deps = $finder->getAllFilesDependingOn(__FILE__ . 'asdf'); + $this->assertCount(0, $deps); + } +} diff --git a/tests/FileTest.php b/tests/FileTest.php new file mode 100644 index 0000000..52cf60f --- /dev/null +++ b/tests/FileTest.php @@ -0,0 +1,18 @@ +assertSame([], $file->dependencies); + $this->assertSame([], $file->declares); + } +} diff --git a/tests/Fixtures/Circular/A.php b/tests/Fixtures/Circular/A.php new file mode 100644 index 0000000..79ad573 --- /dev/null +++ b/tests/Fixtures/Circular/A.php @@ -0,0 +1,12 @@ +create(ParserFactory::PREFER_PHP7); + $nodes = $parser->parse(<<<'PHP' +addVisitor(new NameResolver()); + $traverser->addVisitor(new ParentConnectorVisitor()); + $nameCollector = new NameCollector(); + $traverser->addVisitor($nameCollector); + $traverser->traverse($nodes); + + $this->assertCount(2, $nameCollector->resolvedNames); + $this->assertSame([ + '\F\Bar\Baz', + '\Full\Name', + ], $nameCollector->resolvedNames); + } +}