diff --git a/README.md b/README.md index 3635145..c3e7b3b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ function doBar() { } +// TODO: phpunit/phpunit:5.3 This has to be fixed when updating phpunit to 5.3.x or higher +function doFooBar() { + +} + +// TODO: php:>8 drop this polyfill when php 8.x is required + ``` ## Supported todo formats @@ -34,8 +41,8 @@ When a text is given after the date, this text will be picked up for the PHPStan The comment can expire by different constraints, examples are: - by date with format of `YYYY-MM-DD` -- by semantic version matched against the project itself - +- by a semantic version constraint matched against the project itself +- by a semantic version constraint matched against a Composer dependency see examples of different comment variants which are supported: @@ -46,8 +53,8 @@ see examples of different comment variants which are supported: // todo - 2023-12-14 fix it // todo 2023-12-14 - fix it -// TODO@lars 2023-12-14 - fix it -// TODO@lars: 2023-12-14 - fix it +// TODO@staabm 2023-12-14 - fix it +// TODO@markus: 2023-12-14 - fix it /* * other text @@ -58,6 +65,9 @@ see examples of different comment variants which are supported: // TODO: <1.0.0 This has to be in the first major release // TODO >123.4: Must fix this or bump the version + +// TODO: phpunit/phpunit:<5 This has to be fixed before updating to phpunit 5.x +// TODO@markus: phpunit/phpunit:5.3 This has to be fixed when updating phpunit to 5.3.x or higher ``` ## Configuration @@ -103,6 +113,9 @@ parameters: ### Reference version By default version-todo-comments are checked against `"nextMajor"` version. + +_Note: The reference version is not applied to package-version-todo-comments._ + This is determined by fetching the latest local available git tag and incrementing the major version number. This behaviour can be configured with the `referenceVersion` option. diff --git a/composer.json b/composer.json index ca2b404..2562ec7 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ ], "require": { "php": "^7.4 || ^8.0", + "composer-runtime-api": "^2", "composer/semver": "^3.4", "nikolaposa/version": "^4.1" }, diff --git a/extension.neon b/extension.neon index 466818f..9c368f4 100644 --- a/extension.neon +++ b/extension.neon @@ -34,6 +34,12 @@ services: arguments: - %todo_by.singleGitRepo% + - + class: staabm\PHPStanTodoBy\TodoByPackageVersionRule + tags: [phpstan.rules.rule] + arguments: + - %currentWorkingDirectory% + - class: staabm\PHPStanTodoBy\utils\GitTagFetcher diff --git a/src/TodoByPackageVersionRule.php b/src/TodoByPackageVersionRule.php new file mode 100644 index 0000000..47e2778 --- /dev/null +++ b/src/TodoByPackageVersionRule.php @@ -0,0 +1,206 @@ + + */ +final class TodoByPackageVersionRule implements Rule +{ + private const COMPARATORS = ['<', '>', '=']; + + // composer package-name pattern from https://getcomposer.org/doc/04-schema.md#name + private const PATTERN = <<<'REGEXP' +{ + @?TODO # possible @ prefix + @?[a-zA-Z0-9_-]*\s* # optional username + \s*[:-]?\s* # optional colon or hyphen + (?:(?P(php|[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*)):) # "php" or a composer package name, followed by ":" + (?P[^\s:\-]+) # version constraint + \s*[:-]?\s* # optional colon or hyphen + (?P.*) # rest of line as comment text +}ix +REGEXP; + + private ExpiredCommentErrorBuilder $errorBuilder; + + private string $workingDirectory; + + public function __construct( + string $workingDirectory, + ExpiredCommentErrorBuilder $errorBuilder + ) { + $this->workingDirectory = $workingDirectory; + $this->errorBuilder = $errorBuilder; + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $it = CommentMatcher::matchComments($node, self::PATTERN); + + $errors = []; + foreach($it as $comment => $matches) { + /** @var array> $matches */ + foreach ($matches as $match) { + + $package = $match['package'][0]; + $version = $match['version'][0]; + $todoText = trim($match['comment'][0]); + + // assume a min version constraint, when the comment does not specify a comparator + if ($this->getVersionComparator($version) === null) { + $version = '>='. $version; + } + + if ($package === 'php') { + $satisfiesOrError = $this->satisfiesPhpPlatformPackage($package, $version, $comment, $match[0][1]); + } else { + $satisfiesOrError = $this->satisfiesInstalledPackage($package, $version, $comment, $match[0][1]); + } + + if ($satisfiesOrError instanceof \PHPStan\Rules\RuleError) { + $errors[] = $satisfiesOrError; + continue; + } + if ($satisfiesOrError === false) { + continue; + } + + // If there is further text, append it. + if ($todoText !== '') { + $errorMessage = '"'. $package .'" version requirement "'. $version .'" satisfied: '. rtrim($todoText, '.') ."."; + } else { + $errorMessage = '"'. $package .'" version requirement "'. $version .'" satisfied.'; + } + + $errors[] = $this->errorBuilder->buildError( + $comment, + $errorMessage, + null, + $match[0][1] + ); + } + } + + return $errors; + } + + /** + * @return bool|\PHPStan\Rules\RuleError + */ + private function satisfiesPhpPlatformPackage(string $package, string $version, Comment $comment, int $wholeMatchStartOffset) + { + $versionParser = new VersionParser(); + + // @phpstan-ignore-next-line missing bc promise + $config = ComposerHelper::getComposerConfig($this->workingDirectory); + if ($config === null) { + return $this->errorBuilder->buildError( + $comment, + 'Unable to find composer.json in '. $this->workingDirectory, + null, + $wholeMatchStartOffset + ); + } + + if ( + !isset($config['require']) + || !is_array($config['require']) + || !isset($config['require']['php']) + || !is_string($config['require']['php']) + ) { + return $this->errorBuilder->buildError( + $comment, + 'Missing php platform requirement in '. $this->workingDirectory .'/composer.json', + null, + $wholeMatchStartOffset + ); + } + + $provided = $versionParser->parseConstraints( + $config['require']['php'] + ); + + try { + $constraint = $versionParser->parseConstraints($version); + } catch (\UnexpectedValueException $e) { + return $this->errorBuilder->buildError( + $comment, + 'Invalid version constraint "' . $version . '" for package "' . $package . '".', + null, + $wholeMatchStartOffset + ); + } + + return $provided->matches($constraint); + } + + /** + * @return bool|\PHPStan\Rules\RuleError + */ + private function satisfiesInstalledPackage(string $package, string $version, Comment $comment, int $wholeMatchStartOffset) + { + $versionParser = new VersionParser(); + + // see https://getcomposer.org/doc/07-runtime.md#installed-versions + if (!InstalledVersions::isInstalled($package)) { + return $this->errorBuilder->buildError( + $comment, + 'Package "' . $package . '" is not installed via Composer.', + null, + $wholeMatchStartOffset + ); + } + + try { + return InstalledVersions::satisfies($versionParser, $package, $version); + } catch (\UnexpectedValueException $e) { + return $this->errorBuilder->buildError( + $comment, + 'Invalid version constraint "' . $version . '" for package "' . $package . '".', + null, + $wholeMatchStartOffset + ); + } + } + + private function getVersionComparator(string $version): ?string + { + $comparator = null; + for($i = 0; $i < strlen($version); $i++) { + if (!in_array($version[$i], self::COMPARATORS)) { + break; + } + $comparator .= $version[$i]; + } + + return $comparator; + + } +} diff --git a/src/TodoByVersionRule.php b/src/TodoByVersionRule.php index 741ef3a..947002e 100644 --- a/src/TodoByVersionRule.php +++ b/src/TodoByVersionRule.php @@ -148,6 +148,5 @@ private function getVersionComparator(string $version): ?string } return $comparator; - } } diff --git a/src/utils/CommentMatcher.php b/src/utils/CommentMatcher.php index cdf45bd..499c556 100644 --- a/src/utils/CommentMatcher.php +++ b/src/utils/CommentMatcher.php @@ -37,6 +37,10 @@ public static function matchComments(Node $node, string $pattern): iterable preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER) === false || count($matches) === 0 ) { + if (preg_last_error() !== PREG_NO_ERROR) { + throw new \RuntimeException('Error in PCRE: '. preg_last_error_msg()); + } + continue; } diff --git a/tests/TodoByPackageVersionRuleTest.php b/tests/TodoByPackageVersionRuleTest.php new file mode 100644 index 0000000..ac33e73 --- /dev/null +++ b/tests/TodoByPackageVersionRuleTest.php @@ -0,0 +1,79 @@ + + */ +final class TodoByPackageVersionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new TodoByPackageVersionRule( + dirname(__DIR__), + new ExpiredCommentErrorBuilder(true), + ); + } + + /** + * @param list $errors + * @dataProvider provideErrors + */ + public function testRule(array $errors): void + { + $this->analyse([__DIR__ . '/data/packageVersion.php'], $errors); + } + + /** + * @return iterable}> + */ + public static function provideErrors(): iterable + { + yield [ + [ + [ + '"phpunit/phpunit" version requirement "<50" satisfied: This has to be fixed before updating to phpunit 50.x.', + 5 + ], + [ + '"phpunit/phpunit" version requirement ">=5.3" satisfied: This has to be fixed when updating to phpunit 5.3.* or higher.', + 8 + ], + [ + 'Package "not-installed/package" is not installed via Composer.', + 11 + ], + [ + '"phpunit/phpunit" version requirement "<10" satisfied.', + 14 + ], + [ + '"phpunit/phpunit" version requirement "<11" satisfied.', + 15 + ], + [ + 'Invalid version constraint "7.3" satisfied: drop this code after min-version raise.', + 19 + ], + [ + '"php" version requirement ">=7" satisfied: drop this code after min-version raise.', + 20 + ] + ] + ]; + } + +} diff --git a/tests/data/packageVersion.php b/tests/data/packageVersion.php new file mode 100644 index 0000000..ac05ffb --- /dev/null +++ b/tests/data/packageVersion.php @@ -0,0 +1,21 @@ +7.3 drop this code after min-version raise +// TODO php:7 drop this code after min-version raise +// TODO php:9