diff --git a/.env b/.env index 8dd6973..bd95dd7 100644 --- a/.env +++ b/.env @@ -1,9 +1,13 @@ WARDEN_ENV_NAME=depends WARDEN_ENV_TYPE=laravel WARDEN_WEB_ROOT=/ -WARDEN_PHP=1 -COMPOSER_VERSION=2 -PHP_VERSION=7.4 + TRAEFIK_DOMAIN=depends.test TRAEFIK_SUBDOMAIN=app + +WARDEN_PHP=1 +PHP_VERSION=8.0 +PHP_XDEBUG_3=1 + +COMPOSER_VERSION=2 WARDEN_COMPOSER_DIR=./.composer diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 644a50d..05f8ceb 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -1,21 +1,81 @@ -name: Static Analysis Test +name: Continuous Integration on: [push] jobs: + phpunit: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + coverage: xdebug + tools: composer:v2 + + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Install Dependencies + run: composer install + + - name: Test + run: vendor/bin/phpunit tests --testdox --coverage-clover coverage.xml + + - name: Upload to CodeCov + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: true + phpstan: name: Static Analysis Check runs-on: ubuntu-latest container: - image: davidalger/php:7.2 + image: davidalger/php:8.0 steps: - name: Checkout Repository - uses: actions/checkout@v1 + uses: actions/checkout@v2 + + - name: Install Dependencies + run: composer2 install - - name: Install Prestissimo - run: composer global require hirak/prestissimo + - name: Run PHPStan + run: vendor/bin/phpstan + + phpcs: + name: Code Style (PSR-12) + runs-on: ubuntu-latest + container: + image: davidalger/php:8.0 + steps: + - name: Checkout Repository + uses: actions/checkout@v2 - name: Install Dependencies + run: composer2 install + + - name: PHP Code Sniffer + run: vendor/bin/phpcs + + infection: + name: Infection Check + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + coverage: xdebug + tools: composer:v2 + + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Install dependencies run: composer install - - name: Run PHPStan - run: vendor/bin/phpstan analyse src --level max + - name: Check for Mutants + run: vendor/bin/infection --threads=$(nproc) --no-progress --logger-github diff --git a/README.md b/README.md index 0952064..477f7a0 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,38 @@ -# The @dependency Annotation -![Static Analysis Test](https://github.com/navarr/dependency-annotation/workflows/Static%20Analysis%20Test/badge.svg) +# The #[Dependency] Attribute +[![Latest Stable Version](http://poser.pugx.org/navarr/dependency-annotation/v)](https://packagist.org/packages/navarr/dependency-annotation) +[![Total Downloads](http://poser.pugx.org/navarr/dependency-annotation/downloads)](https://packagist.org/packages/navarr/dependency-annotation) +[![Latest Unstable Version](http://poser.pugx.org/navarr/dependency-annotation/v/unstable)](https://packagist.org/packages/navarr/dependency-annotation) +[![License](http://poser.pugx.org/navarr/dependency-annotation/license)](https://packagist.org/packages/navarr/dependency-annotation) +![Tests](https://github.com/navarr/dependency-annotation/actions/workflows/commit.yml/badge.svg) +![Code Coverage](https://codecov.io/gh/navarr/dependency-annotation/branch/main/graph/badge.svg?token=BHTKOZZDR3) -This project supplies a Composer plugin that adds a command (`why-block`) that interprets the PHP `@dependency` - annotation. +This project supplies a Composer plugin that adds a command (`why-block`) that interprets a PHP `#[Dependency]` + attribute. -## How to use the `@dependency` annotation +## How to use the `#[Dependency]` annotation -Simply include a `@dependency` annotation in any slash-based comment block, in the following format: +Simply include a `#[Dependency]` attribute on any attributable target in the following format: - @dependency composer-package:version-constraint [Explanation] + #[Navarr\Depends\Annotation\Dependency('package', 'versionConstraint', 'reason')] -All fields except the explanation are mandatory. Adding an explanation is _highly recommended_, however. +This FQN may be imported, in which case you can simply use `#[Dependency(...)]` -The version-constraint field cannot contain spaces (even if surrounded by quotes). +All fields except the explanation are mandatory. Adding an explanation is _highly recommended_, however. ## How to process reasons not to upgrade a composer dependency -If you are using the `@dependency` annotation thoroughly, and you are having issues updating a composer dependency, you +If you are using the `#[Dependency]` annotation thoroughly, and you are having issues updating a composer dependency, you can use the command `composer why-block composer-package version` -This will output a list of files containing a `@dependency` annotation on composer-package with a version-constraint - that cannot be fulfilled by your specified version. +This will output a list of files containing a `#[Dependency]` annotation on composer-package with a version-constraint + that cannot be fulfilled by the specified version. ## How to install `composer global require navarr/dependency-annotation` + +## Compatibility with v1 + +For speed, version 2 automatically excludes the legacy `@dependency` annotation in favor of the PHP8 `#[Dependency]` +attribute. While transitioning, you may specify the `-l` or `--include-legacy-annotations` flag to the `why-block` +command to force it to process v1 annotations as well. diff --git a/composer.json b/composer.json index 6d06ea8..b0905db 100644 --- a/composer.json +++ b/composer.json @@ -4,23 +4,47 @@ "type": "composer-plugin", "license": "MIT", "require": { - "php": "^7.2", - "composer-plugin-api": "^1|^2", - "composer/composer": "^1|^2", + "php": "^7.1|^8", + "composer-plugin-api": "^2", + "composer/composer": "^2", "composer/semver": "^1|^2|^3", - "symfony/console": "^5" + "symfony/console": "^5", + "nikic/php-parser": "^4", + "navarr/attribute-dependency": "^1.0.1", + "php-di/php-di": "^6" + }, + "suggest": { + "ext-fileinfo": "Use MIME types for PHP file detection", + "ext-json": "Required to use JSON Output", + "ext-simplexml": "Required to use XML Output" }, "require-dev": { + "php": "^8", "roave/security-advisories": "dev-master", - "phpstan/phpstan": "^0.12.32" + "phpstan/phpstan": "^0.12.32", + "phpunit/phpunit": "^9.5", + "infection/infection": "^0.23.0", + "squizlabs/php_codesniffer": "^3.6", + "jetbrains/phpstorm-attributes": "^1.0" }, "autoload": { "psr-4": { "Navarr\\Depends\\": "src/" + }, + "files": [ + "src/polyfills/preg_last_error_msg.func.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Navarr\\Depends\\Test\\": "tests/" } }, "extra": { - "class": "Navarr\\Depends\\Plugin" + "class": "Navarr\\Depends\\Controller\\Composer\\ComposerPlugin", + "branch-alias": { + "dev-php8-annotation": "2.x-dev" + } }, "archive": { "exclude": [ diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..1ed51b8 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,25 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "." + }, + "mutators": { + "@default": true, + "ConcatOperandRemoval": { + "ignore": [ + "Navarr\\Depends\\Parser\\AstParser::parse::82", + "Navarr\\Depends\\Parser\\AstParser::parse::83" + ] + }, + "Concat": { + "ignore": [ + "Navarr\\Depends\\Parser\\AstParser::parse::82", + "Navarr\\Depends\\Parser\\AstParser::parse::83" + ] + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1447257 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,5 @@ + + + + src + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1ff472c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..5ffbe0a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + tests + + + + src + + + src/polyfills + src/Proxy/MimeDeterminer.php + + + diff --git a/src/Command/WhyBlockCommand.php b/src/Command/WhyBlockCommand.php index 3e7f4f9..a10e151 100644 --- a/src/Command/WhyBlockCommand.php +++ b/src/Command/WhyBlockCommand.php @@ -1,242 +1,50 @@ setName('why-block') - ->addArgument('package', InputArgument::REQUIRED, 'Package to inspect') - ->addArgument('version', InputArgument::REQUIRED, 'Version you want to update it to') - ->addOption( - self::ROOT_DEPS, - ['r'], - InputOption::VALUE_NONE, - 'Whether or not to search root dependencies for the @dependency annotation', - null - ) - ->addOption( - self::ALL_DEPS, - ['a'], - InputOption::VALUE_NONE, - 'Whether or not to search all dependencies for the @dependency annotation', - null - ); - } - - /** - * @dependency symfony/console:^5 InputInterface's getOption method - * @dependency symfony/console:^5 OutputInterface's writeln method - * - * @param InputInterface $input - * @param OutputInterface $output - * @return int Exit code - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - /** @var Composer $composer required indicates it can never be null. */ - $composer = $this->getComposer(true); - - // Always check the base files - $results = static::getAllFilesForAutoload('.', $composer->getPackage()->getAutoload()); - - $packages = []; - // If we're checking dependencies, grab all packages - if ($input->getOption(self::ROOT_DEPS) || $input->getOption(self::ALL_DEPS)) { - $packages = $composer->getRepositoryManager()->getLocalRepository()->getPackages(); - } - - // If we're only checking root dependencies, determine them and filter down `$packages` - if ($input->getOption(self::ROOT_DEPS)) { - $requires = array_map( - static function (Link $link) { - return $link->getTarget(); - }, - $composer->getPackage()->getRequires() - ); - $packages = array_filter( - $packages, - static function (PackageInterface $package) use ($requires) { - return in_array($package->getName(), $requires, true); - } - ); - } - - // Find all files for the packages - foreach ($packages as $package) { - $path = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $package->getName()); - $results = static::getAllFilesForAutoload($path, $package->getAutoload(), $results); - } - - $found = false; - foreach ($results as $file) { - $contents = file_get_contents($file); - if ($contents === false) { - continue; - } - $matches = []; - - // Double slash comments - preg_match_all( - '#//\s+@(dependency|composerDependency)\s+([^:\s]+):(\S+)\s(.*)?(?=$)#im', - $contents, - $matches, - PREG_OFFSET_CAPTURE - ); - $found = $this->processMatches($matches, $input, $contents, $output, $file); - - // Slash asterisk comments. We're cheating here and only using an asterisk as indicator. False - // positives possible. - preg_match_all( - '#\*\s+@(dependency|composerDependency)\s+([^:]+):(\S+) ?(.*)$#im', - $contents, - $matches, - PREG_OFFSET_CAPTURE - ); - $found = $this->processMatches($matches, $input, $contents, $output, $file) || $found; - } - - if (!$found) { - /** @var string $package */ - $package = $input->getArgument('package'); - $output->writeln('We found no documented reason for ' . $package . ' being blocked.'); - } - - return 0; - } - - /** - * Process any potential matches after a Regex Search for dependency annotations - * - * @param array $matches Output of {@see preg_match_all} with PREG_OFFSET_CAPTURE flag set - * @param InputInterface $input - * @param string $contents Entire contents of a PHP file - * @param OutputInterface $output - * @param string $file Filename - * @return bool Whether or not any matches were found in the file - */ - protected function processMatches( - array $matches, - InputInterface $input, - string $contents, - OutputInterface $output, - string $file - ): bool { - $found = false; - - $matchCount = count($matches[0]) ?? 0; - for ($match = 0; $match < $matchCount; ++$match) { - $package = strtolower($matches[static::INLINE_MATCH_PACKAGE][$match][0]); - if ($package !== $input->getArgument('package')) { - continue; - } - - /** @var string $version */ - $version = $input->getArgument('version'); - - // @dependency composer/semver:^1|^2|^3 We need the Semver::satisfies static method - if (Semver::satisfies($version, $matches[static::INLINE_MATCH_VERSION][$match][0])) { - continue; - } - - $found = true; - - $pos = $matches[0][$match][1]; - $line = substr_count(mb_substr($contents, 0, $pos), "\n") + 1; - - $reason = trim($matches[static::INLINE_MATCH_REASON][$match][0]) ?? 'No reason provided'; - if (substr($reason, -2) === '*/') { - $reason = trim(substr($reason, 0, -2)); - } - - $output->writeln( - $file . ':' . $line . ' ' . - $reason . ' ' . - '(' . $matches[static::INLINE_MATCH_VERSION][$match][0] . ')' - ); - } - return $found; + $this->aggregator = $aggregator; + $this->outputHandler = $outputHandler; } - /** - * Retrieve all PHP files out of the directories and files listed in the autoload directive - * - * @param string $basePath Base directory of the package who's autoload we're processing - * @param array $autoload Result of {@see PackageInterface::getAutoload()} - * @param string[] $results Array of file paths to merge with - * @return string[] File paths - */ - private static function getAllFilesForAutoload(string $basePath, array $autoload, array $results = []): array + public function execute(string $packageToSearchFor, string $versionToCompareTo): int { - foreach ($autoload as $map) { - foreach ($map as $dir) { - $realDir = realpath($basePath . DIRECTORY_SEPARATOR . $dir); - if ($realDir === false) { - continue; + $dependencies = $this->aggregator->aggregate(); + + /** @var DeclaredDependency[] $failingDependencies Declarations of the provided package that don't match the + * version requirement + */ + $failingDependencies = array_filter( + $dependencies, + static function (DeclaredDependency $attribute) use ($packageToSearchFor, $versionToCompareTo) { + if ($attribute->getPackage() === null || $attribute->getConstraint() === null) { + return false; } - $results = static::getAllPhpFiles($realDir, $results); - } - } - return $results; - } - - /** - * Find all PHP files by recursively searching a directory - * - * @param string $dir Directory to search recursively - * @param string[] $results Array of file paths to merge with - * @return string[] File paths - */ - private static function getAllPhpFiles(string $dir, array $results = []): array - { - $files = scandir($dir); - if ($files === false) { - return $results; - } - - foreach ($files as $key => $value) { - $path = realpath($dir . DIRECTORY_SEPARATOR . $value); - if ($path === false) { - continue; - } - - if (!is_dir($path) && substr($path, -4) === '.php') { - $results[] = $path; - } elseif (!in_array($value, ['.', '..'])) { - $results = static::getAllPhpFiles($path, $results); + return strtolower($attribute->getPackage()) === strtolower($packageToSearchFor) + && !Semver::satisfies($versionToCompareTo, $attribute->getConstraint()); } - } + ); - return $results; + return $this->outputHandler->output($failingDependencies, $packageToSearchFor, $versionToCompareTo); } } diff --git a/src/Command/WhyBlockCommand/CsvOutputHandler.php b/src/Command/WhyBlockCommand/CsvOutputHandler.php new file mode 100644 index 0000000..4d1a20d --- /dev/null +++ b/src/Command/WhyBlockCommand/CsvOutputHandler.php @@ -0,0 +1,55 @@ +includeHeader = $includeHeader; + $this->writer = $writer; + } + + /** + * @param DeclaredDependency[] $dependencies + * @param string $packageToSearchFor + * @param string $versionToCompareTo + * @return int + */ + public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int + { + if (!$this->writer->canWrite()) { + throw new RuntimeException('Unable to output to stdin'); + } + if ($this->includeHeader) { + $this->writer->writeCsv(['File', 'Line #', 'Constraint Specified', 'Reasoning']); + } + foreach ($dependencies as $dependency) { + $this->writer->writeCsv( + [ + $dependency->getFile() ?: '', + $dependency->getLine() ?: '', + $dependency->getConstraint() ?: '', + $dependency->getReason() ?: '', + ] + ); + } + return count($dependencies) < 1 ? 0 : 1; + } +} diff --git a/src/Command/WhyBlockCommand/JsonOutputHandler.php b/src/Command/WhyBlockCommand/JsonOutputHandler.php new file mode 100644 index 0000000..9e3cecd --- /dev/null +++ b/src/Command/WhyBlockCommand/JsonOutputHandler.php @@ -0,0 +1,64 @@ +writer = $writer; + } + + /** + * @param DeclaredDependency[] $dependencies + * @param string $packageToSearchFor + * @param string $versionToCompareTo + * @return int + */ + public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int + { + if (!function_exists('json_encode')) { + throw new RuntimeException('PHP JSON Extension must be installed to use JSON Output'); + } + if (!$this->writer->canWrite()) { + throw new RuntimeException('Unable to output to stdin'); + } + $this->writer->write( + json_encode( + array_map( + static function (DeclaredDependency $dependency) { + $result = []; + if ($dependency->getFile() !== null) { + $result['file'] = $dependency->getFile(); + } + if ($dependency->getLine() !== null) { + $result['line'] = $dependency->getLine(); + } + if ($dependency->getConstraint() !== null) { + $result['declaredConstraint'] = $dependency->getConstraint(); + } + if ($dependency->getReason() !== null) { + $result['reason'] = $dependency->getReason(); + } + return $result; + }, + $dependencies + ) + ) ?: '' + ); + return count($dependencies) < 1 ? 0 : 1; + } +} diff --git a/src/Command/WhyBlockCommand/OutputHandlerInterface.php b/src/Command/WhyBlockCommand/OutputHandlerInterface.php new file mode 100644 index 0000000..781233e --- /dev/null +++ b/src/Command/WhyBlockCommand/OutputHandlerInterface.php @@ -0,0 +1,22 @@ +output = $output; + } + + /** + * @param DeclaredDependency[] $dependencies + * @param string $packageToSearchFor + * @param string $versionToCompareTo + * @return int + */ + public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int + { + if (count($dependencies) < 1) { + $this->output->writeln("We found no reason to block {$packageToSearchFor} v{$versionToCompareTo}"); + return 0; + } + + foreach ($dependencies as $dependency) { + $this->output->writeln( + ($dependency->getReference() !== null + ? str_replace(getcwd() . '/', '', $dependency->getReference()) + : 'Unknown File') + . ': ' . $dependency->getReason() + . ' (' . $dependency->getConstraint() . ')' + ); + } + return 1; + } +} diff --git a/src/Command/WhyBlockCommand/XmlOutputHandler.php b/src/Command/WhyBlockCommand/XmlOutputHandler.php new file mode 100644 index 0000000..20b4191 --- /dev/null +++ b/src/Command/WhyBlockCommand/XmlOutputHandler.php @@ -0,0 +1,65 @@ +writer = $writer; + } + + /** + * @param DeclaredDependency[] $dependencies + * @param string $packageToSearchFor + * @param string $versionToCompareTo + * @return int + */ + public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int + { + if (!class_exists(SimpleXMLElement::class)) { + throw new RuntimeException('PHP SimpleXML extension required to use XML Output'); + } + if (!$this->writer->canWrite()) { + throw new RuntimeException('Unable to output to stdin'); + } + + $results = new SimpleXMLElement(''); + $results->addAttribute('testedPackage', $packageToSearchFor); + $results->addAttribute('packageVersion', $versionToCompareTo); + foreach ($dependencies as $dependency) { + if ($dependency->getReason() !== null) { + $reason = $results->addChild('reason', $dependency->getReason()); + } else { + $reason = $results->addChild('reason'); + } + if ($dependency->getFile() !== null) { + $reason->addAttribute('file', $dependency->getFile()); + } + if ($dependency->getLine() !== null) { + $reason->addAttribute('line', $dependency->getLine()); + } + if ($dependency->getConstraint() !== null) { + $reason->addAttribute('constraint', $dependency->getConstraint()); + } + } + + $this->writer->write($results->asXML() ?: ''); + + return count($dependencies) < 1 ? 0 : 1; + } +} diff --git a/src/Controller/Composer/ComposerCommand.php b/src/Controller/Composer/ComposerCommand.php new file mode 100644 index 0000000..4c2bea1 --- /dev/null +++ b/src/Controller/Composer/ComposerCommand.php @@ -0,0 +1,172 @@ + CsvOutputHandler::class, + self::FORMAT_TEXT => StandardOutputHandler::class, + self::FORMAT_JSON => JsonOutputHandler::class, + self::FORMAT_XML => XmlOutputHandler::class, + ]; + + // phpcs:disable Generic.Files.LineLength.TooLong -- Attribute support pre PHP 8 + #[Dependency('symfony/console', '^5', 'Command\'s setName, addArgument and addOption methods as well as InputArgument\'s constants of REQUIRED and VALUE_NONE')] + #[Dependency('php-di/php-di', '^6', 'DI\ContainerBuilder::addDefinitions and the existence of the DI\autowire function')] + // phpcs:enable Generic.Files.LineLength.TooLong + protected function configure(): void + { + $this->setName('why-block') + ->addArgument('package', InputArgument::REQUIRED, 'Package to inspect') + ->addArgument('version', InputArgument::REQUIRED, 'Version you want to update it to') + ->addOption( + self::OUTPUT_FORMAT, + ['f'], + InputOption::VALUE_OPTIONAL, + 'Format to output results in. Accepted values: text, csv, json, xml', + 'text' + ) + ->addOption( + self::FAIL_ON_ERROR, + ['e'], + InputOption::VALUE_NONE, + 'Immediately fail on parsing errors' + ) + ->addOption( + self::LEGACY_ANNOTATION, + ['l'], + InputOption::VALUE_NONE, + 'Include old @dependency/@composerDependency annotations in search' + ) + ->addOption( + self::ROOT_DEPS, + ['r'], + InputOption::VALUE_NONE, + 'Search root dependencies for the @dependency annotation' + ) + ->addOption( + self::ALL_DEPS, + ['a'], + InputOption::VALUE_NONE, + 'Search all dependencies for the @dependency annotation' + ); + } + + #[Dependency('symfony/console', '^5', 'InputInterface::getOption and OutputInterface::writeln')] + protected function execute( + InputInterface $input, + OutputInterface $output + ): int { + $packageToSearchFor = $input->getArgument('package'); + $versionToCompareTo = $input->getArgument('version'); + $outputFormat = $input->getOption(self::OUTPUT_FORMAT); + + if (!is_string($packageToSearchFor)) { + throw new InvalidArgumentException('Only one package is allowed'); + } + if (!is_string($versionToCompareTo)) { + throw new InvalidArgumentException('Only one version is allowed'); + } + if (!is_string($outputFormat)) { + throw new InvalidArgumentException('Only one output format is allowed'); + } + + $outputFormat = strtolower($outputFormat); + if (!in_array($outputFormat, static::ACCEPTABLE_FORMATS)) { + $outputFormat = 'text'; + } + + if ($input->getOption(static::ALL_DEPS)) { + $composerScope = ComposerScopeDeterminer::SCOPE_ALL_DEPENDENCIES; + } elseif ($input->getOption(static::ROOT_DEPS)) { + $composerScope = ComposerScopeDeterminer::SCOPE_ROOT_DEPENDENCIES; + } else { + $composerScope = ComposerScopeDeterminer::SCOPE_PROJECT_ONLY; + } + + $containerBuilder = new ContainerBuilder(); + $containerBuilder->addDefinitions( + [ + InputInterface::class => $input, + OutputInterface::class => $output, + IssueHandlerInterface::class => $input->getOption(static::FAIL_ON_ERROR) + ? FailOnIssueHandler::class + : NotifyOnIssueHandler::class, + Composer::class => $this->getComposer(true), + ParserInterface::class => static function (ContainerInterface $container) use ($input) { + $parsers = [$container->get(AstParser::class)]; + if ($input->getOption(static::LEGACY_ANNOTATION)) { + $parsers[] = $container->get(LegacyParser::class); + } + return new ParserPool($parsers); + }, + WriterInterface::class => autowire(StdOutWriter::class), + ComposerScopeDeterminer::class => autowire(ComposerScopeDeterminer::class) + ->property('scope', $composerScope), + ScopeDeterminerInterface::class => autowire(ComposerScopeDeterminer::class), + OutputHandlerInterface::class => autowire(static::FORMAT_MAPPER[$outputFormat]), + ] + ); + $container = $containerBuilder->build(); + + /** @var WhyBlockCommand $command */ + $command = $container->get(WhyBlockCommand::class); + return $command->execute($packageToSearchFor, $versionToCompareTo); + } +} diff --git a/src/Plugin.php b/src/Controller/Composer/ComposerPlugin.php similarity index 63% rename from src/Plugin.php rename to src/Controller/Composer/ComposerPlugin.php index 478174c..6c687ff 100644 --- a/src/Plugin.php +++ b/src/Controller/Composer/ComposerPlugin.php @@ -1,26 +1,25 @@ file = $file; + $this->line = $line; + $this->reference = $reference; + $this->package = $package; + $this->constraint = $constraint; + $this->reason = $reason; + $this->required = $required; + } + + public function getFile(): ?string + { + return $this->file; + } + + public function getLine(): ?string + { + return $this->line; + } + + public function getReference(): ?string + { + return $this->reference; + } + + public function getPackage(): ?string + { + return $this->package; + } + + public function getConstraint(): ?string + { + return $this->constraint; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function isRequired(): bool + { + return $this->required; + } +} diff --git a/src/Data/ReferenceAdder.php b/src/Data/ReferenceAdder.php new file mode 100644 index 0000000..ddd92de --- /dev/null +++ b/src/Data/ReferenceAdder.php @@ -0,0 +1,24 @@ +getLine(), + "{$file}:{$dependency->getLine()}", + $dependency->getPackage(), + $dependency->getConstraint(), + $dependency->getReason() + ); + } +} diff --git a/src/DeclaredDependencyAggregator.php b/src/DeclaredDependencyAggregator.php new file mode 100644 index 0000000..a7a2904 --- /dev/null +++ b/src/DeclaredDependencyAggregator.php @@ -0,0 +1,74 @@ +parser = $parser; + $this->scopeDeterminer = $scopeDeterminer; + $this->issueHandler = $issueHandler; + $this->referenceAdder = $referenceAdder; + } + + /** + * @return DeclaredDependency[] + */ + #[Pure] + public function aggregate(): array + { + $dependencies = [[]]; + $files = $this->scopeDeterminer->getFiles(); + foreach ($files as $file) { + $contents = @file_get_contents($file); + if ($contents === false) { + $this->handleIssue("Could not read from file '{$file}'"); + continue; + } + $dependencies[] = array_map( + function (DeclaredDependency $dependency) use ($file) { + return $this->referenceAdder->add($dependency, $file); + }, + $this->parser->parse($contents) + ); + } + return array_merge(...$dependencies); + } + + private function handleIssue(string $description): void + { + if ($this->issueHandler) { + $this->issueHandler->execute($description); + } + } +} diff --git a/src/Factory/CollectingFactory.php b/src/Factory/CollectingFactory.php new file mode 100644 index 0000000..d035324 --- /dev/null +++ b/src/Factory/CollectingFactory.php @@ -0,0 +1,37 @@ +container = $container; + } + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function create(): Collecting + { + return $this->container->make(Collecting::class); + } +} diff --git a/src/Factory/FindingVisitorFactory.php b/src/Factory/FindingVisitorFactory.php new file mode 100644 index 0000000..663e6c0 --- /dev/null +++ b/src/Factory/FindingVisitorFactory.php @@ -0,0 +1,38 @@ +container = $container; + } + + /** + * @param mixed[] $args Arguments for {@link FindingVisitor}'s constructor + * @throws DependencyException + * @throws NotFoundException + */ + public function create(array $args = []): FindingVisitor + { + return $this->container->make(FindingVisitor::class, $args); + } +} diff --git a/src/Factory/NodeTraverserFactory.php b/src/Factory/NodeTraverserFactory.php new file mode 100644 index 0000000..b5f7f0f --- /dev/null +++ b/src/Factory/NodeTraverserFactory.php @@ -0,0 +1,38 @@ +container = $container; + } + + /** + * @param mixed[] $args Arguments for {@link NodeTraverser}'s constructor + * @throws DependencyException + * @throws NotFoundException + */ + public function create(array $args = []): NodeTraverser + { + return $this->container->make(NodeTraverser::class, $args); + } +} diff --git a/src/IssueHandler/FailOnIssueHandler.php b/src/IssueHandler/FailOnIssueHandler.php new file mode 100644 index 0000000..09b9373 --- /dev/null +++ b/src/IssueHandler/FailOnIssueHandler.php @@ -0,0 +1,19 @@ +output = $output; + } + + #[Dependency('symfony/console', '^5', 'OutputInterface->getErrorOutput and ->writeln')] + #[Dependency('symfony/console', '^5', 'ConsoleOutputInterface\'s existence')] + public function execute( + string $description + ): void { + $output = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + $output->writeln("{$description}"); + } +} diff --git a/src/Parser/AstParser.php b/src/Parser/AstParser.php new file mode 100644 index 0000000..fa45d16 --- /dev/null +++ b/src/Parser/AstParser.php @@ -0,0 +1,136 @@ +parserFactory = $parserFactory; + $this->nameResolver = $nameResolver; + $this->nodeTraverserFactory = $nodeTraverserFactory; + $this->errorCollectorFactory = $errorCollectorFactory; + $this->findingVisitorFactory = $findingVisitorFactory; + $this->issueHandler = $issueHandler; + } + + #[Dependency('nikic/php-parser', '^4')] + #[Dependency('navarr/attribute-dependency', '^1', 'Existence of Dependency attribute')] + public function parse( + string $contents + ): array { + $astParser = $this->parserFactory->create(ParserFactory::PREFER_PHP7); + $nameResolver = $this->nameResolver; + $finder = $this->findingVisitorFactory->create( + [ + 'filterCallback' => static function (Node $node) { + return $node instanceof Attribute + && $node->name->toString() === Dependency::class; + }, + ] + ); + + $traverser = $this->nodeTraverserFactory->create(); + $traverser->addVisitor($nameResolver); + $traverser->addVisitor($finder); + + $errorCollector = $this->errorCollectorFactory->create(); + + $ast = $astParser->parse($contents, $errorCollector); + if ($ast === null || $errorCollector->hasErrors()) { + $description = "Could not parse contents:" . PHP_EOL . ' - ' + . implode(PHP_EOL . ' - ', $errorCollector->getErrors()); + $this->handleIssue($description); + return []; + } + + $traverser->traverse($ast); + + $attributes = $finder->getFoundNodes(); + + $argIndex = [ + 0 => 'package', + 1 => 'versionConstraint', + 2 => 'reason', + 3 => 'required', + ]; + + return array_filter( + array_map( + static function (Attribute $node) use ($argIndex): ?DeclaredDependency { + $attributes = []; + foreach ($node->args as $i => $arg) { + $name = $arg->name->name ?? $argIndex[$i]; + if (!is_string($name)) { + return null; + } + if ($arg->value instanceof Node\Scalar\String_) { + $attributes[$name] = $arg->value->value; + } + } + if (!isset($attributes['package'])) { + return null; + } + return new DeclaredDependency( + null, + (string)$node->getLine(), + null, + $attributes['package'], + $attributes['versionConstraint'] ?? null, + $attributes['reason'] ?? null, + isset($attributes['required']) && (bool)$attributes['required'] + ); + }, + $attributes + ) + ); + } + + private function handleIssue(string $description): void + { + if ($this->issueHandler !== null) { + $this->issueHandler->execute($description); + } + } +} diff --git a/src/Parser/LegacyParser.php b/src/Parser/LegacyParser.php new file mode 100644 index 0000000..f6c5e29 --- /dev/null +++ b/src/Parser/LegacyParser.php @@ -0,0 +1,93 @@ +issueHandler = $issueHandler; + } + + public function parse(string $contents): array + { + // We can leave this as an empty array b/c processMatches returns _at least_ an empty array + // Otherwise, array_merge will fail + $results = []; + + // Double slash comments + $result = @preg_match_all( + '#(^\h*\*+|\h*//|\h*/\*+)\s+' . + '@(dependency|composerDependency)\h+' . + '([\w/-]+)' . + '(:([!~\d^|&,<>=\-\w.]+))?' . + '(\h+([^\v]+))?#im', + $contents, + $matches, + PREG_OFFSET_CAPTURE + ); + if ($result === false) { + $this->handleIssue('Regex error: ' . preg_last_error_msg()); + } + $results[] = $this->processMatches($matches, $contents); + + return array_merge(...$results); + } + + /** + * @param string[][] $matches + * @param string $contents + * @return DeclaredDependency[] + */ + private function processMatches(array $matches, string $contents): array + { + $results = []; + + $matchCount = count($matches[0]) ?? 0; + for ($match = 0; $match < $matchCount; ++$match) { + $package = strtolower($matches[static::INLINE_MATCH_PACKAGE][$match][0]); + $version = $matches[static::INLINE_MATCH_VERSION][$match][0]; + + $line = substr_count(mb_substr($contents, 0, (int)$matches[0][$match][1]), "\n") + 1; + + $reason = trim($matches[static::INLINE_MATCH_REASON][$match][0]) ?? 'No reason provided'; + if (substr($reason, -2) === '*/') { + $reason = trim(substr($reason, 0, -2)); + } + + $results[] = new DeclaredDependency( + null, + (string)$line, + null, + $package, + $version, + $reason + ); + } + + return $results; + } + + private function handleIssue(string $description): void + { + if ($this->issueHandler !== null) { + $this->issueHandler->execute($description); + } + } +} diff --git a/src/Parser/ParserInterface.php b/src/Parser/ParserInterface.php new file mode 100644 index 0000000..ae48c64 --- /dev/null +++ b/src/Parser/ParserInterface.php @@ -0,0 +1,19 @@ +=7', 'Throwing TypeError')] + public function __construct( + array $parsers = [] + ) { + array_walk( + $parsers, + static function ($parser) { + if (!$parser instanceof ParserInterface) { + throw new TypeError('All parsers must implement ' . ParserInterface::class); + } + } + ); + $this->parsers = $parsers; + } + + public function parse(string $contents): array + { + $result = [[]]; + foreach ($this->parsers as $parser) { + $result[] = $parser->parse($contents); + } + + return array_merge(...$result); + } +} diff --git a/src/Proxy/MimeDeterminer.php b/src/Proxy/MimeDeterminer.php new file mode 100644 index 0000000..02d563e --- /dev/null +++ b/src/Proxy/MimeDeterminer.php @@ -0,0 +1,29 @@ +resource = $resource; + } + + public function canWrite(): bool + { + return $this->resource !== false; + } + + public function write(string $data): void + { + if ($this->resource) { + fputs($this->resource, $data); + } + } + + /** + * @param string[] $data + */ + public function writeCsv(array $data): void + { + if ($this->resource) { + fputcsv($this->resource, $data); + } + } +} diff --git a/src/Proxy/WriterInterface.php b/src/Proxy/WriterInterface.php new file mode 100644 index 0000000..8457572 --- /dev/null +++ b/src/Proxy/WriterInterface.php @@ -0,0 +1,19 @@ +composer = $composer; + $this->phpFileDeterminer = $phpFileDeterminer; + $this->scope = $scope; + } + + #[Pure] + #[Dependency('composer/composer', '^2', 'Composer primary class getPackage')] + #[Dependency('composer/composer', '^2', 'RootPackageInterface getAutoload and getRequires')] + public function getFiles(): array + { + $package = $this->composer->getPackage(); + + $autoload = $package->getAutoload(); + $files = $this->getAllFilesForAutoload('.', $autoload); + + /** @var Link[] $packages */ + $packages = []; + if ($this->scope >= static::SCOPE_ROOT_DEPENDENCIES) { + $packages = $package->getRequires(); + } + + if ($this->scope >= static::SCOPE_ALL_DEPENDENCIES) { + for ($i = 0; $i < count($packages); ++$i) { + $package = $packages[$i]; + $requirements = $this->getRequirementsForPackage($package); + + // I wonder if this is better or worse than recursive + $packages = array_merge( + $packages, + array_diff($requirements, $packages) + ); + } + } + + foreach ($packages as $package) { + $path = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $package->getTarget()); + + $foundPackage = $this->composer->getRepositoryManager() + ->getLocalRepository() + ->findPackage($package->getTarget(), $package->getConstraint()); + + if (!$foundPackage) { + continue; + } + + $this->getAllFilesForAutoload( + $path, + $foundPackage->getAutoload(), + $files + ); + } + + return array_unique($files); + } + + /** + * Get all packages required by a package + * + * @param Link $package + * @return Link[] + */ + private function getRequirementsForPackage(Link $package): array + { + $package = $this->composer->getRepositoryManager() + ->getLocalRepository() + ->findPackage($package->getTarget(), $package->getConstraint()); + + return $package === null ? [] : $package->getRequires(); + } + + /** + * Retrieve all PHP files out of the directories and files listed in the autoload directive + * + * @param string $basePath Base directory of the package who's autoload we're processing + * @param array $autoload Result of {@see PackageInterface::getAutoload()} + * @param string[] $results Array of file paths to merge with + * @return string[] File paths + */ + #[Pure] + private function getAllFilesForAutoload( + string $basePath, + array $autoload, + array $results = [] + ): array { + foreach ($autoload as $map) { + foreach ($map as $dir) { + $realDir = realpath($basePath . DIRECTORY_SEPARATOR . $dir); + if ($realDir === false) { + continue; + } + if (is_file($realDir)) { + $results[] = $realDir; + continue; + } + if (is_dir($realDir)) { + $results = $this->getAllPhpFiles($realDir, $results); + } + } + } + return $results; + } + + /** + * Find all PHP files by recursively searching a directory + * + * @param string $dir Directory to search recursively + * @param string[] $results Array of file paths to merge with + * @return string[] File paths + */ + private function getAllPhpFiles(string $dir, array $results = []): array + { + $files = scandir($dir); + if ($files === false) { + return $results; + } + + foreach ($files as $value) { + $path = realpath($dir . DIRECTORY_SEPARATOR . $value); + if ($path === false) { + continue; + } + + if (is_file($path) && $this->phpFileDeterminer->isPhp($path)) { + $results[] = $path; + } elseif (is_dir($path) && !in_array($value, ['.', '..'])) { + $results = $this->getAllPhpFiles($path, $results); + } + } + + return $results; + } +} diff --git a/src/ScopeDeterminer/PhpFileDeterminer.php b/src/ScopeDeterminer/PhpFileDeterminer.php new file mode 100644 index 0000000..ad8af7b --- /dev/null +++ b/src/ScopeDeterminer/PhpFileDeterminer.php @@ -0,0 +1,61 @@ +mimeDeterminer = $mimeDeterminer; + } + + #[Pure] + public function isPhp( + string $file + ): bool { + // There are so many approaches we could take here, but we're going with this one: + + $mimeType = $this->mimeDeterminer->getMimeType($file); + if ($mimeType && in_array($mimeType, static::PHP_MIME_TYPES)) { + // Mime type is PHP. That's good enough for me + return true; + } + + $parts = explode('.', $file); + if (in_array(end($parts), static::PHP_FILE_EXTENSIONS)) { + // Extension matches list - so it was probably intended to be PHP + return true; + } + + return false; + } +} diff --git a/src/ScopeDeterminer/ScopeDeterminerInterface.php b/src/ScopeDeterminer/ScopeDeterminerInterface.php new file mode 100644 index 0000000..30406e5 --- /dev/null +++ b/src/ScopeDeterminer/ScopeDeterminerInterface.php @@ -0,0 +1,18 @@ + 'No error', + PREG_INTERNAL_ERROR => 'Internal Error', + PREG_BACKTRACK_LIMIT_ERROR => 'Backtrack limit exhausted', + PREG_RECURSION_LIMIT_ERROR => 'Recursion limit exhausted', + PREG_BAD_UTF8_ERROR => 'Malformed UTF-8 characters, possibly incorrectly encoded', + PREG_BAD_UTF8_OFFSET_ERROR => 'The offset did not correspond to the beginning of a valid UTF-8 code point', + PREG_JIT_STACKLIMIT_ERROR => 'JIT stack limit exhausted', + ][preg_last_error()]; + } +} diff --git a/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php b/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php new file mode 100644 index 0000000..b6dd9ea --- /dev/null +++ b/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php @@ -0,0 +1,58 @@ +container = new Container(); + } + + /** + * @param mixed[] $args Arguments to pass to constructor (may be named) + * @return OutputHandlerInterface + */ + abstract protected function createHandler(array $args = []): OutputHandlerInterface; + + /** + * @return StdOutWriter&MockObject + */ + protected function defaultWriterMock() + { + $writer = $this->createMock(StdOutWriter::class); + $writer->method('canWrite') + ->willReturn(true); + + return $writer; + } + + public function testReturnValueIsZeroWhenThereAreNoDependencies(): void + { + $handler = $this->createHandler(); + $this->assertEquals(0, $handler->output([], '', '')); + } + + public function testReturnValueIsOneWhenThereAreDependencies(): void + { + $handler = $this->createHandler(); + $this->assertEquals(1, $handler->output([new DeclaredDependency()], '', '')); + } +} diff --git a/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php b/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php new file mode 100644 index 0000000..0de0268 --- /dev/null +++ b/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php @@ -0,0 +1,98 @@ +defaultWriterMock(); + } + return $this->container->make(CsvOutputHandler::class, $args); + } + + public function testOutputHeaderIsNotWrittenWhenIncludeHeaderIsFalse(): void + { + $writer = $this->defaultWriterMock(); + $writer->expects($this->never()) + ->method('writeCsv'); + + $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => false]); + $handler->output([], '', ''); + } + + public function testHeaderIsWrittenByDefault(): void + { + $writer = $this->defaultWriterMock(); + $writer->expects($this->atLeastOnce()) + ->method('writeCsv') + ->with(['File', 'Line #', 'Constraint Specified', 'Reasoning']); + + $handler = $this->container->make(CsvOutputHandler::class, ['writer' => $writer]); + $handler->output([], '', ''); + } + + public function testExceptionWhenCanWriteReturnsFalse(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to output to stdin'); + + $mock = $this->createMock(WriterInterface::class); + $mock->expects($this->once()) + ->method('canWrite') + ->willReturn(false); + + $handler = $this->createHandler(['writer' => $mock]); + $handler->output([], '', ''); + } + + public function testOutputHeaderIsWrittenWhenIncludeHeaderIsTrue(): void + { + $writer = $this->defaultWriterMock(); + $writer->expects($this->atLeastOnce()) + ->method('writeCsv') + ->with(['File', 'Line #', 'Constraint Specified', 'Reasoning']); + + $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => true]); + $handler->output([], '', ''); + } + + public function testCsvOutputIsInExpectedFormat(): void + { + $file = uniqid(); + $line = uniqid(); + $constraint = uniqid(); + $reason = uniqid(); + + $writer = $this->defaultWriterMock(); + $writer->expects($this->once()) + ->method('writeCsv') + ->with([$file, $line, $constraint, $reason]); + + $dependency = new DeclaredDependency( + file: $file, + line: $line, + constraint: $constraint, + reason: $reason + ); + + $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => false]); + $handler->output([$dependency], '', ''); + } +} diff --git a/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php b/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php new file mode 100644 index 0000000..7c40e10 --- /dev/null +++ b/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php @@ -0,0 +1,88 @@ +defaultWriterMock(); + } + + return $this->container->make(JsonOutputHandler::class, $args); + } + + public function testExceptionWhenCanWriteReturnsFalse(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to output to stdin'); + + $mock = $this->createMock(WriterInterface::class); + $mock->expects($this->once()) + ->method('canWrite') + ->willReturn(false); + + $handler = $this->createHandler(['writer' => $mock]); + $handler->output([], '', ''); + } + + public function expectedResultProvider(): array + { + $file = uniqid(); + $line = uniqid(); + $constraint = uniqid(); + $reason = uniqid(); + + return [ + 'No Results' => ['[]', []], + 'Only File' => [json_encode([['file' => $file]]), [new DeclaredDependency(file: $file)]], + 'Only Line' => [json_encode([['line' => $line]]), [new DeclaredDependency(line: $line)]], + 'Only Constraint' => [ + json_encode([['declaredConstraint' => $constraint]]), + [new DeclaredDependency(constraint: $constraint)], + ], + 'Only Reason' => [json_encode([['reason' => $reason]]), [new DeclaredDependency(reason: $reason)]], + 'All in order' => [ + json_encode( + [ + [ + 'file' => $file, + 'line' => $line, + 'declaredConstraint' => $constraint, + 'reason' => $reason, + ], + ] + ), + [new DeclaredDependency(file: $file, line: $line, constraint: $constraint, reason: $reason)], + ], + ]; + } + + /** + * @param string $expectedResult + * @param DeclaredDependency[] $dependencies + * @dataProvider expectedResultProvider + */ + public function testOutputFormatIsAsExpected(string $expectedResult, array $dependencies = []): void + { + $writer = $this->defaultWriterMock(); + $writer->expects($this->once()) + ->method('write') + ->with($expectedResult); + + $handler = $this->createHandler(['writer' => $writer]); + $handler->output($dependencies, '', ''); + } +} diff --git a/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php b/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php new file mode 100644 index 0000000..fe98a8f --- /dev/null +++ b/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php @@ -0,0 +1,99 @@ +createMock(OutputInterface::class); + } + return $this->container->make(StandardOutputHandler::class, $args); + } + + public function testHumanReadableOutputWhenNoResults(): void + { + $packageToSearchFor = uniqid(); + $versionToCompareTo = uniqid(); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with("We found no reason to block {$packageToSearchFor} v{$versionToCompareTo}"); + + $handler = $this->createHandler(['output' => $output]); + $handler->output([], $packageToSearchFor, $versionToCompareTo); + } + + public function testGeneralOutputFormat(): void + { + $reference = uniqid(); + $constraint = uniqid(); + $reason = uniqid(); + + $dependency = new DeclaredDependency( + reference: $reference, + constraint: $constraint, + reason: $reason + ); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with("{$reference}: {$reason} ({$constraint})"); + + $handler = $this->createHandler(['output' => $output]); + $handler->output([$dependency], '', ''); + } + + public function testReferencelessOutputFormat(): void + { + $constraint = uniqid(); + $reason = uniqid(); + + $dependency = new DeclaredDependency( + constraint: $constraint, + reason: $reason + ); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with("Unknown File: {$reason} ({$constraint})"); + + $handler = $this->createHandler(['output' => $output]); + $handler->output([$dependency], '', ''); + } + + public function testCurrentWorkingDirectoryIsRemovedFromReference(): void + { + $reference = uniqid(); + $constraint = uniqid(); + $reason = uniqid(); + + $dependency = new DeclaredDependency( + reference: getcwd() . '/' .$reference, + constraint: $constraint, + reason: $reason + ); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with("{$reference}: {$reason} ({$constraint})"); + + $handler = $this->createHandler(['output' => $output]); + $handler->output([$dependency], '', ''); + } +} diff --git a/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php b/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php new file mode 100644 index 0000000..bb7879d --- /dev/null +++ b/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php @@ -0,0 +1,48 @@ +content .= $data; + } + + /** + * @param string[] $data + */ + public function writeCsv(array $data): void + { + $resource = fopen('php://memory', 'r+'); + if ($resource === false) { + throw new RuntimeException('could not open memory for read/write'); + } + fputcsv($resource, $data); + rewind($resource); + $this->content .= stream_get_contents($resource); + fclose($resource); + } + + public function getContents(): string + { + return $this->content; + } +} diff --git a/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php b/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php new file mode 100644 index 0000000..5c057e2 --- /dev/null +++ b/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php @@ -0,0 +1,164 @@ +defaultWriterMock(); + } + + return $this->container->make(XmlOutputHandler::class, $args); + } + + public function testExceptionWhenCanWriteReturnsFalse(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to output to stdin'); + + $mock = $this->createMock(WriterInterface::class); + $mock->expects($this->once()) + ->method('canWrite') + ->willReturn(false); + + $handler = $this->createHandler(['writer' => $mock]); + $handler->output([], '', ''); + } + + #[ArrayShape([ + 'xml' => SimpleXMLElement::class, + 'dep1' => ['string','string','string','string'], + 'dep2' => ['string','string','string','string'] + ])] + public function testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes(): array + { + $packageToSearchFor = uniqid(); + $versionToCompareTo = uniqid(); + + $writer = new ContainerWriter(); + + $reason1 = uniqid(); + $file1 = uniqid(); + $line1 = uniqid(); + $constraint1 = uniqid(); + $reason2 = uniqid(); + $file2 = uniqid(); + $line2 = uniqid(); + $constraint2 = uniqid(); + + $dependency1 = new DeclaredDependency( + file: $file1, + line: $line1, + constraint: $constraint1, + reason: $reason1 + ); + + $dependency2 = new DeclaredDependency( + file: $file2, + line: $line2, + constraint: $constraint2, + reason: $reason2 + ); + + $handler = $this->createHandler(['writer' => $writer]); + $handler->output([$dependency1, $dependency2], $packageToSearchFor, $versionToCompareTo); + + $xml = new SimpleXMLElement($writer->getContents()); + $this->assertEquals('block-reasons', $xml->getName()); + $this->assertEquals($packageToSearchFor, $xml['testedPackage']); + $this->assertEquals($versionToCompareTo, $xml['packageVersion']); + + return [ + 'xml' => $xml, + 'dep1' => [$file1, $line1, $constraint1, $reason1], + 'dep2' => [$file2, $line2, $constraint2, $reason2], + ]; + } + + /** + * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes + */ + public function testReasonTagExistsForEachDependency( + #[ArrayShape([ + 'xml' => SimpleXMLElement::class, + 'dep1' => ['string', 'string', 'string', 'string'], + 'dep2' => ['string', 'string', 'string', 'string'], + ])] array $result + ): void { + $xml = $result['xml']; + + $children = $xml->children(); + $this->assertCount(2, $children); + } + + /** + * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes + */ + public function testDependency1ContentIsInReasons( + #[ArrayShape([ + 'xml' => SimpleXMLElement::class, + 'dep1' => ['string', 'string', 'string', 'string'], + 'dep2' => ['string', 'string', 'string', 'string'], + ])] array $result + ): void { + $xml = $result['xml']; + list($file, $line, $constraint, $reason) = $result['dep1']; + + $this->assertCount(3, $xml->reason->attributes()); + $this->assertEquals($reason, (string)$xml->reason[0]); + $this->assertEquals($file, (string)$xml->reason[0]['file']); + $this->assertEquals($line, (string)$xml->reason[0]['line']); + $this->assertEquals($constraint, (string)$xml->reason[0]['constraint']); + } + + /** + * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes + */ + public function testDependency2ContentIsInReasons( + #[ArrayShape([ + 'xml' => SimpleXMLElement::class, + 'dep1' => ['string', 'string', 'string', 'string'], + 'dep2' => ['string', 'string', 'string', 'string'], + ])] array $result + ): void { + $xml = $result['xml']; + list($file, $line, $constraint, $reason) = $result['dep2']; + + $this->assertCount(3, $xml->reason->attributes()); + $this->assertEquals($reason, (string)$xml->reason[1]); + $this->assertEquals($file, (string)$xml->reason[1]['file']); + $this->assertEquals($line, (string)$xml->reason[1]['line']); + $this->assertEquals($constraint, (string)$xml->reason[1]['constraint']); + } + + public function testAttributesAreExcludedWhenNotInDependency() + { + $dependency = new DeclaredDependency(); + $writer = new ContainerWriter(); + $handler = new XmlOutputHandler($writer); + + $handler->output([$dependency], '', ''); + + $blankXml = << + + +XML; + + $this->assertEquals($blankXml, $writer->getContents()); + } +} diff --git a/tests/Command/WhyBlockCommandTest.php b/tests/Command/WhyBlockCommandTest.php new file mode 100644 index 0000000..8aa0dc8 --- /dev/null +++ b/tests/Command/WhyBlockCommandTest.php @@ -0,0 +1,221 @@ +createMock(DeclaredDependencyAggregator::class); + $aggregatorMock->expects($this->once()) + ->method('aggregate') + ->willReturn($dependencies); + + return $aggregatorMock; + } + + /** + * @param string $package + * @param string $version + * @param DeclaredDependency[] $expectedResult Defaults to empty + * @return OutputHandlerInterface&MockObject + */ + private function createOutputHandlerMock(string $package, string $version, array $expectedResult = []) + { + $outputHandlerMock = $this->createMock(OutputHandlerInterface::class); + $outputHandlerMock->expects($this->once()) + ->method('output') + ->with($expectedResult, $package, $version); + + return $outputHandlerMock; + } + + public function testNonTargetedPackagesAreNotReturned(): void + { + $constraint = '>=100'; + + $dependency = new DeclaredDependency( + package: static::TEST_PACKAGE_UNUSED, + constraint: $constraint + ); + + $failingVersion = '5'; + + $this->assertFalse(Semver::satisfies($failingVersion, $constraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency]); + $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute(static::TEST_PACKAGE, $failingVersion); + } + + public function testSatisfactoryConstraintsAreNotReturned(): void + { + $constraint = '<100'; + + $dependency = new DeclaredDependency( + package: static::TEST_PACKAGE, + constraint: $constraint + ); + + $testVersion = '5'; + + $this->assertTrue(Semver::satisfies($testVersion, $constraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency]); + + $outputHandler = $this->createOutputHandlerMock(static::TEST_PACKAGE, $testVersion); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandler); + $command->execute(static::TEST_PACKAGE, $testVersion); + } + + public function testDependencyWithoutPackageIsNotReturned(): void + { + $constraint = '>=100'; + + $dependency = new DeclaredDependency( + constraint: $constraint + ); + + $failingVersion = '5'; + + $this->assertFalse(Semver::satisfies($failingVersion, $constraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency]); + $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute(static::TEST_PACKAGE, $failingVersion); + } + + public function testDependencyWithoutConstraintIsNotReturned(): void + { + $constraint = '>=100'; + + $dependency = new DeclaredDependency( + package: static::TEST_PACKAGE + ); + + $failingVersion = '5'; + + $this->assertFalse(Semver::satisfies($failingVersion, $constraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency]); + $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute(static::TEST_PACKAGE, $failingVersion); + } + + public function testDependenciesFailingConstraintsAreReturned(): void + { + $failingConstraint = '>=100'; + + $dependency1 = new DeclaredDependency( + package: static::TEST_PACKAGE, + constraint: $failingConstraint, + reason: uniqid() + ); + + $dependency2 = new DeclaredDependency( + package: static::TEST_PACKAGE, + constraint: $failingConstraint, + reason: uniqid() + ); + + $failingVersion = '5'; + + $this->assertFalse(Semver::satisfies($failingVersion, $failingConstraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]); + $outputHandlerMock = $this->createOutputHandlerMock( + static::TEST_PACKAGE, + $failingVersion, + [$dependency1, $dependency2] + ); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute(static::TEST_PACKAGE, $failingVersion); + } + + public function testLowercaseVersionOfPackageNamesIsUsedForMatch(): void + { + $failingConstraint = '>=100'; + + $dependency1 = new DeclaredDependency( + package: 'navarr/example-PACKAGE', + constraint: $failingConstraint, + reason: uniqid() + ); + + $dependency2 = new DeclaredDependency( + package: 'navarr/EXAMPLE-package', + constraint: $failingConstraint, + reason: uniqid() + ); + + $failingVersion = '5'; + + $this->assertFalse(Semver::satisfies($failingVersion, $failingConstraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]); + $outputHandlerMock = $this->createOutputHandlerMock( + 'NAVARR/example-package', + $failingVersion, + [$dependency1, $dependency2] + ); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute('NAVARR/example-package', $failingVersion); + } + + public function testDependenciesPassingConstraintsAreNotReturned(): void + { + $constraint = '<100'; + + $dependency1 = new DeclaredDependency( + package: static::TEST_PACKAGE, + constraint: $constraint, + reason: uniqid() + ); + + $dependency2 = new DeclaredDependency( + package: static::TEST_PACKAGE, + constraint: $constraint, + reason: uniqid() + ); + + $checkedVersion = '5'; + + $this->assertTrue(Semver::satisfies($checkedVersion, $constraint)); + + $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]); + $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $checkedVersion); + + $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock); + $command->execute(static::TEST_PACKAGE, $checkedVersion); + } +} diff --git a/tests/Controller/Composer/ComposerPluginTest.php b/tests/Controller/Composer/ComposerPluginTest.php new file mode 100644 index 0000000..24d780b --- /dev/null +++ b/tests/Controller/Composer/ComposerPluginTest.php @@ -0,0 +1,34 @@ +getCapabilities(); + $this->assertArrayHasKey(CommandProvider::class, $capabilities); + $this->assertEquals(ComposerPlugin::class, $capabilities[CommandProvider::class]); + } + + public function testContainsOnlyComposerCommands() + { + $plugin = new ComposerPlugin(); + $commands = $plugin->getCommands(); + + $this->assertIsArray($commands); + $this->assertCount(1, $commands); + $command = end($commands); + $this->assertInstanceOf(ComposerCommand::class, $command); + } +} diff --git a/tests/Data/DeclaredDependencyTest.php b/tests/Data/DeclaredDependencyTest.php new file mode 100644 index 0000000..cdf7816 --- /dev/null +++ b/tests/Data/DeclaredDependencyTest.php @@ -0,0 +1,64 @@ +assertEquals($file, $dependency->getFile()); + $this->assertEquals($line, $dependency->getLine()); + $this->assertEquals($reference, $dependency->getReference()); + $this->assertEquals($package, $dependency->getPackage()); + $this->assertEquals($version, $dependency->getConstraint()); + $this->assertEquals($reason, $dependency->getReason()); + $this->assertFalse($dependency->isRequired()); + } + + public function testDefaultValues() + { + $dependency = new DeclaredDependency(); + + $this->assertNull($dependency->getFile()); + $this->assertNull($dependency->getLine()); + $this->assertNull($dependency->getReference()); + $this->assertNull($dependency->getPackage()); + $this->assertNull($dependency->getConstraint()); + $this->assertNull($dependency->getReason()); + $this->assertTrue($dependency->isRequired()); + } + + public function testRequiredAttributeReturnsProvidedValue() + { + $dependency = new DeclaredDependency(required: true); + $this->assertTrue($dependency->isRequired()); + + $dependency = new DeclaredDependency(required: false); + $this->assertFalse($dependency->isRequired()); + } +} diff --git a/tests/Data/ReferenceAdderTest.php b/tests/Data/ReferenceAdderTest.php new file mode 100644 index 0000000..4b8442a --- /dev/null +++ b/tests/Data/ReferenceAdderTest.php @@ -0,0 +1,61 @@ +assertEquals($package, $dependency->getPackage()); + $this->assertEquals($constraint, $dependency->getConstraint()); + $this->assertEquals($reason, $dependency->getReason()); + $this->assertEquals($line, $dependency->getLine()); + $this->assertNull($dependency->getFile()); + $this->assertNull($dependency->getReference()); + + $referenceAdder = new ReferenceAdder(); + $dependency2 = $referenceAdder->add( + $dependency, + $file + ); + + // Ensure original hasn't changed + $this->assertEquals($package, $dependency->getPackage()); + $this->assertEquals($constraint, $dependency->getConstraint()); + $this->assertEquals($reason, $dependency->getReason()); + $this->assertEquals($line, $dependency->getLine()); + $this->assertNull($dependency->getFile()); + $this->assertNull($dependency->getReference()); + + // Check new + $this->assertEquals($package, $dependency2->getPackage()); + $this->assertEquals($constraint, $dependency2->getConstraint()); + $this->assertEquals($reason, $dependency2->getReason()); + $this->assertEquals($line, $dependency2->getLine()); + $this->assertEquals($file, $dependency2->getFile()); + $this->assertEquals("{$file}:{$line}", $dependency2->getReference()); + } +} diff --git a/tests/DeclaredDependencyAggregatorTest.php b/tests/DeclaredDependencyAggregatorTest.php new file mode 100644 index 0000000..ef5e263 --- /dev/null +++ b/tests/DeclaredDependencyAggregatorTest.php @@ -0,0 +1,143 @@ +createMock(ScopeDeterminerInterface::class); + $scopeDeterminerMock->expects($this->once()) + ->method('getFiles') + ->willReturn($files); + + $parserMock = $this->createMock(ParserInterface::class); + $parserMock->expects($this->exactly(3)) + ->method('parse') + ->withConsecutive( + // How can we update this so that we test each one was put through, but not necessarily consecutively? + [file_get_contents($files[0])], + [file_get_contents($files[1])], + [file_get_contents($files[2])] + ); + + $aggregator = new DeclaredDependencyAggregator( + $parserMock, + $scopeDeterminerMock, + new ReferenceAdder(), + new FailOnIssueHandler() + ); + + $aggregator->aggregate(); + } + + public function testIssueHandlerAlertedWhenFileCannotBeOpened(): void + { + $file = __DIR__ . '/_data/nonExistantFile'; + $files = [$file]; + + $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class); + $scopeDeterminerMock->expects($this->once()) + ->method('getFiles') + ->willReturn($files); + + $parserMock = $this->createMock(ParserInterface::class); + $parserMock->method('parse') + ->willReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not read from file '{$file}'"); + + $aggregator = new DeclaredDependencyAggregator( + $parserMock, + $scopeDeterminerMock, + new ReferenceAdder(), + new FailOnIssueHandler() + ); + + $aggregator->aggregate(); + } + + public function testParserNotCalledWhenFileCannotBeOpened(): void + { + $file = __DIR__ . '/_data/nonExistantFile'; + $files = [$file]; + + $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class); + $scopeDeterminerMock->expects($this->once()) + ->method('getFiles') + ->willReturn($files); + + $parserMock = $this->createMock(ParserInterface::class); + $parserMock->expects($this->never()) + ->method('parse'); + + $aggregator = new DeclaredDependencyAggregator( + $parserMock, + $scopeDeterminerMock, + new ReferenceAdder(), + null + ); + + $aggregator->aggregate(); + } + + public function testFileAndReferenceAreAddedToDeclaredDependency(): void + { + $file = __DIR__ . '/_data/attributeUsage.php'; + $files = [$file]; + + $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class); + $scopeDeterminerMock->expects($this->once()) + ->method('getFiles') + ->willReturn($files); + + $line = '5'; + + $dependency = new DeclaredDependency(line: $line); + $this->assertNull($dependency->getFile()); + $this->assertNull($dependency->getReference()); + + $parserMock = $this->createMock(ParserInterface::class); + $parserMock->expects($this->once()) + ->method('parse') + ->willReturn([$dependency]); + + $aggregator = new DeclaredDependencyAggregator( + $parserMock, + $scopeDeterminerMock, + new ReferenceAdder(), + null + ); + + $results = $aggregator->aggregate(); + $this->assertCount(1, $results); + + $newDependency = $results[0]; + + $this->assertEquals($line, $newDependency->getLine()); + $this->assertEquals($file, $newDependency->getFile()); + $this->assertEquals("{$file}:{$line}", $newDependency->getReference()); + } +} diff --git a/tests/Factory/AbstractFactoryTest.php b/tests/Factory/AbstractFactoryTest.php new file mode 100644 index 0000000..475d1ca --- /dev/null +++ b/tests/Factory/AbstractFactoryTest.php @@ -0,0 +1,38 @@ +container = new Container(); + } + + public function testEachCreateReturnsNewInstance() + { + $factory = $this->create(); + + $instance1 = $factory->create(); + $instance2 = $factory->create(); + + $this->assertFalse($instance1 === $instance2); + } + + abstract public function testFactoryProducesExpectedType(); +} diff --git a/tests/Factory/CollectingFactoryTest.php b/tests/Factory/CollectingFactoryTest.php new file mode 100644 index 0000000..2de4874 --- /dev/null +++ b/tests/Factory/CollectingFactoryTest.php @@ -0,0 +1,25 @@ +container->get(CollectingFactory::class); + } + + public function testFactoryProducesExpectedType() + { + $this->assertInstanceOf(Collecting::class, $this->create()->create()); + } +} diff --git a/tests/Factory/FindingVisitorFactoryTest.php b/tests/Factory/FindingVisitorFactoryTest.php new file mode 100644 index 0000000..2c79a12 --- /dev/null +++ b/tests/Factory/FindingVisitorFactoryTest.php @@ -0,0 +1,48 @@ +container->get(FindingVisitorFactory::class); + } + + public function testEachCreateReturnsNewInstance() + { + $factory = $this->create(); + + $args = [ + 'filterCallback' => static function () { + }, + ]; + + $instance1 = $factory->create($args); + $instance2 = $factory->create($args); + + $this->assertFalse($instance1 === $instance2); + } + + public function testFactoryProducesExpectedType() + { + $this->assertInstanceOf( + FindingVisitor::class, + $this->create()->create( + [ + 'filterCallback' => static function () { + }, + ] + ) + ); + } +} diff --git a/tests/Factory/NodeTraverserFactoryTest.php b/tests/Factory/NodeTraverserFactoryTest.php new file mode 100644 index 0000000..82d6289 --- /dev/null +++ b/tests/Factory/NodeTraverserFactoryTest.php @@ -0,0 +1,25 @@ +container->get(NodeTraverserFactory::class); + } + + public function testFactoryProducesExpectedType() + { + $this->assertInstanceOf(NodeTraverser::class, $this->create()->create()); + } +} diff --git a/tests/IssueHandler/FailOnIssueHandlerTest.php b/tests/IssueHandler/FailOnIssueHandlerTest.php new file mode 100644 index 0000000..ac2fc14 --- /dev/null +++ b/tests/IssueHandler/FailOnIssueHandlerTest.php @@ -0,0 +1,31 @@ +expectException(RuntimeException::class); + + $handler = new FailOnIssueHandler(); + $handler->execute(''); + } + + public function testThrownExceptionContainsDescription() + { + $handler = new FailOnIssueHandler(); + + $randomMessage = uniqid(); + $this->expectExceptionMessage($randomMessage); + $handler->execute($randomMessage); + } +} diff --git a/tests/IssueHandler/NotifyOnIssueHandlerTest.php b/tests/IssueHandler/NotifyOnIssueHandlerTest.php new file mode 100644 index 0000000..b1db519 --- /dev/null +++ b/tests/IssueHandler/NotifyOnIssueHandlerTest.php @@ -0,0 +1,45 @@ +createMock(OutputInterface::class); + $outputMock->expects($this->once()) + ->method('writeln') + ->with($this->equalTo("{$description}")); + + $handler = new NotifyOnIssueHandler($outputMock); + $handler->execute($description); + } + + public function testErrorOutputUsedIfExists() + { + $description = uniqid(); + + $errorOutputMock = $this->createMock(OutputInterface::class); + $errorOutputMock->expects($this->once()) + ->method('writeln') + ->with($this->equalTo("{$description}")); + + $outputMock = $this->createMock(ConsoleOutputInterface::class); + $outputMock->method('getErrorOutput') + ->willReturn($errorOutputMock); + + $handler = new NotifyOnIssueHandler($outputMock); + $handler->execute($description); + } +} diff --git a/tests/Parser/AstParserTest.php b/tests/Parser/AstParserTest.php new file mode 100644 index 0000000..827a7d5 --- /dev/null +++ b/tests/Parser/AstParserTest.php @@ -0,0 +1,168 @@ +make(AstParser::class, $args); + } + + /** + * @return DeclaredDependency[] + */ + private function getStandardResults(): array + { + $parser = $this->buildAstParser(); + $contents = file_get_contents(__DIR__ . '/' . self::FILE_ATTRIBUTE_USAGE); + return $parser->parse($contents); + } + + /** + * @return DeclaredDependency[] + */ + public function testParserFindsAllAttributes(): array + { + $results = $this->getStandardResults(); + + $this->assertIsArray($results); + $this->assertCount(self::ATTRIBUTE_USAGE_ATTRIBUTE_COUNT, $results); + foreach ($results as $result) { + $this->assertInstanceOf(DeclaredDependency::class, $result); + } + + return $results; + } + + /** + * @depends testParserFindsAllAttributes + * + * @param DeclaredDependency[] + */ + public function testParserFindsAllReasons(array $results) + { + /** A complete list of strings that should be found in the attributes gathered */ + $searchReasons = [ + 'Class Attribute', + 'Constant Attribute', + 'Property Attribute', + 'Method Attribute', + 'Method Parameter Attribute', + 'Function Attribute', + 'Function Parameter Attribute', + 'Mixed Parameter Order', + ]; + + foreach ($searchReasons as $searchReason) { + $this->assertContains( + $searchReason, + array_map( + static function (DeclaredDependency $result) { + return $result->getReason(); + }, + $results + ) + ); + } + } + + /** + * @depends testParserFindsAllAttributes + * + * @param DeclaredDependency[] + */ + public function testParserFindsAllVersions(array $results) + { + /** A complete list of version strings that should be found in the attributes gathered */ + $searchVersions = [ + '^1', + '^2', + '^3', + '^4', + '^5', + '^6', + '^7', + '^8', + '^9', + ]; + + foreach ($searchVersions as $searchVersion) { + $this->assertContains( + $searchVersion, + array_map( + static function (DeclaredDependency $result) { + return $result->getConstraint(); + }, + $results + ) + ); + } + } + + public function testParserFailsOnInvalidFile() + { + $file = __DIR__ . '/' . self::FILE_INVALID; + $contents = file_get_contents($file); + + $parser = $this->buildAstParser(['issueHandler' => new FailOnIssueHandler()]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches("#^Could not parse contents#"); + + $parser->parse($contents); + } + + public function testParserReturnsEmptyResultsOnInvalidFile() + { + $file = __DIR__ . '/' . self::FILE_INVALID; + $contents = file_get_contents($file); + + $parser = $this->buildAstParser(); + $result = $parser->parse($contents); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testParserGracefullyHandlesBadAttributeSyntax() + { + $file = __DIR__ . '/../_data/invalidAttributeUsage.php'; + $contents = file_get_contents($file); + + $parser = $this->buildAstParser(); + $result = $parser->parse($contents); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testEmptyFileReturnsEmptyResults() + { + $file = __DIR__ . '/' . self::EMPTY_FILE; + $contents = file_get_contents($file); + + $parser = $this->buildAstParser(); + $result = $parser->parse($contents); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Parser/LegacyParserTest.php b/tests/Parser/LegacyParserTest.php new file mode 100644 index 0000000..8d7399b --- /dev/null +++ b/tests/Parser/LegacyParserTest.php @@ -0,0 +1,129 @@ +parse($contents); + } + + public function testParserFindsAllAttributes(): array + { + $results = $this->getStandardResults(); + + $this->assertIsArray($results); + $this->assertCount(18, $results); + foreach ($results as $result) { + $this->assertInstanceOf(DeclaredDependency::class, $result); + } + + return $results; + } + + /** + * @param DeclaredDependency[] $results + * @depends testParserFindsAllAttributes + */ + public function testParserTrimsReasons(array $results) + { + foreach ($results as $result) { + /** @var DeclaredDependency $result */ + + // No whitespace + $this->assertFalse(substr($result->getReason(), -1) == ' '); + + // No end-comment symbols + $this->assertFalse(substr($result->getReason(), -2) == '*/'); + } + } + + /** + * @param DeclaredDependency[] $results + * @depends testParserFindsAllAttributes + */ + public function testParserFindsAllRecordedReasons(array $results) + { + $reasons = [ + 'composerDependency with version in big doc', + 'composerDependency without version in big doc', + 'dependency with version in big doc', + 'dependency without version in big doc', + 'dependency with version in small doc', + 'dependency without version in small doc', + 'composerDependency with version in small doc', + 'composerDependency without version in small doc', + 'dependency with version in slash doc after other content', + 'this is a test with the comment ending immediately after the reason', + ]; + + $resultReasons = array_map( + static function (DeclaredDependency $dependency) { + return $dependency->getReason(); + }, + $results + ); + + foreach ($reasons as $reason) { + $this->assertContains( + $reason, + $resultReasons + ); + } + } + + public function testEmptyFileReturnsEmptyResults() + { + $file = __DIR__ . '/' . self::EMPTY_FILE; + + $parser = new LegacyParser(); + $result = $parser->parse(file_get_contents($file)); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testInvalidUnicodeContentGracefullyFails() + { + $content = "a\xff"; + + $parser = new LegacyParser(); + $result = $parser->parse($content); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testIssueHandlerIsUtilized() + { + // I don't like doing this, but I can't reliably make this parser error :| + $handler = new FailOnIssueHandler(); + $parser = new LegacyParser($handler); + + $exceptionMessage = uniqid(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($exceptionMessage); + + $reflectionClass = new \ReflectionClass($parser); + $method = $reflectionClass->getMethod('handleIssue'); + $method->setAccessible(true); + $method->invoke($parser, $exceptionMessage); + } +} diff --git a/tests/Parser/ParserPoolTest.php b/tests/Parser/ParserPoolTest.php new file mode 100644 index 0000000..dd33c99 --- /dev/null +++ b/tests/Parser/ParserPoolTest.php @@ -0,0 +1,60 @@ +expectException(TypeError::class); + $this->expectExceptionMessage('All parsers must implement Navarr\Depends\Parser\ParserInterface'); + + new ParserPool(['test']); + } + + public function testAllParsersAreUsed() + { + $contents = uniqid(); + + $parser1Result = uniqid(); + $parser1 = $this->createMock(ParserInterface::class); + $parser1->expects($this->once()) + ->method('parse') + ->with($contents) + ->willReturn([$parser1Result]); + + $parser2Result = uniqid(); + $parser2 = $this->createMock(ParserInterface::class); + $parser2->expects($this->once()) + ->method('parse') + ->with($contents) + ->willReturn([$parser2Result]); + + $pool = new ParserPool([$parser1, $parser2]); + $result = $pool->parse($contents); + + $this->assertIsArray($result); + $this->assertContains($parser1Result, $result); + $this->assertContains($parser2Result, $result); + } + + public function testHandlesNoResults() + { + $pool = new ParserPool([]); + $result = $pool->parse(''); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Proxy/StdOutWriterTest.php b/tests/Proxy/StdOutWriterTest.php new file mode 100644 index 0000000..2b3bc8b --- /dev/null +++ b/tests/Proxy/StdOutWriterTest.php @@ -0,0 +1,70 @@ +assertFalse($writer->canWrite()); + } + + public function testCanWriteReturnsTrueIfResourceCanBeWrittenTo() + { + $file = fopen('/tmp/example', 'w'); + if ($file === false) { + $this->markTestSkipped('Could not open temporary file'); + } + + $writer = new StdOutWriter($file); + $this->assertTrue($writer->canWrite()); + + fclose($file); + } + + public function testContentIsWritten() + { + $filename = '/tmp/' . uniqid(); + $file = fopen($filename, 'w'); + if ($file === false) { + $this->markTestSkipped('Could not open temporary file'); + } + + $contents = uniqid(); + + $writer = new StdOutWriter($file); + $writer->write($contents); + fclose($file); + + $this->assertEquals($contents, file_get_contents($filename)); + } + + public function testCsvContentIsWritten() + { + $filename = '/tmp/' . uniqid(); + $file = fopen($filename, 'w'); + if ($file === false) { + $this->markTestSkipped('Could not open temporary file'); + } + + $contentA = uniqid(); + $contentB = uniqid(); + $contentC = uniqid(); + + $contentArray = [$contentA, $contentB, $contentC]; + $expectedResult = "{$contentA},{$contentB},{$contentC}\n"; + + $writer = new StdOutWriter($file); + $writer->writeCsv($contentArray); + fclose($file); + + $this->assertEquals($expectedResult, file_get_contents($filename)); + } +} diff --git a/tests/ScopeDeterminer/PhpFileDeterminerTest.php b/tests/ScopeDeterminer/PhpFileDeterminerTest.php new file mode 100644 index 0000000..59fd02e --- /dev/null +++ b/tests/ScopeDeterminer/PhpFileDeterminerTest.php @@ -0,0 +1,71 @@ +createMock(MimeDeterminer::class); + $mockMimeDeterminer->expects($this->once()) + ->method('getMimeType') + ->willReturn($pretendMimeType); + + $determiner = new PhpFileDeterminer($mockMimeDeterminer); + $this->assertTrue($determiner->isPhp(uniqid() . '.html')); + } + } + + public function testFileIsConsideredPhpBasedOnExtension() + { + $determiner = new PhpFileDeterminer(new MimeDeterminer()); + $pretendFileNames = [ + 'hellow.php', + 'im-full-of-html.phtml', + 'im-so-old-why-even-bother.php3', + 'im-not-much-better.php4', + 'i-guess-im-okay-but-who-uses-this.php5', + 'seriously-why-are-you-using-this-format.php7', + 'are-we-not-past-the-20th-century.php8', + ]; + + foreach ($pretendFileNames as $pretendFileName) { + $this->assertTrue( + $determiner->isPhp($pretendFileName) + ); + } + } + + public function testNonPhpFileIsNotConsideredPhp() + { + $file = uniqid() . '.html'; + + $mockMimeDeterminer = $this->createMock(MimeDeterminer::class); + $mockMimeDeterminer->expects($this->once()) + ->method('getMimeType') + ->with($file) + ->willReturn('text/html'); + + $determiner = new PhpFileDeterminer($mockMimeDeterminer); + $this->assertFalse( + $determiner->isPhp($file) + ); + } +} diff --git a/tests/_data/attributeUsage.php b/tests/_data/attributeUsage.php new file mode 100644 index 0000000..0776778 --- /dev/null +++ b/tests/_data/attributeUsage.php @@ -0,0 +1,35 @@ +