diff --git a/composer.json b/composer.json index 31fccfe..463dd67 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "keywords": ["dev", "phpstan", "phpstan-extension", "static analysis"], "require": { - "php": "^7.4 || ^8.0" + "php": "^7.4 || ^8.0", + "composer/semver": "^3.4" }, "require-dev": { diff --git a/src/TodoByVersionRule.php b/src/TodoByVersionRule.php new file mode 100644 index 0000000..ac832af --- /dev/null +++ b/src/TodoByVersionRule.php @@ -0,0 +1,145 @@ + + */ +final class TodoByVersionRule implements Rule +{ + private const COMPARATORS = ['<', '>', '=']; + + private const PATTERN = <<<'REGEXP' +/ +@?TODO # possible @ prefix +@?[a-zA-Z0-9_-]*\s* # optional username +\s*[:-]?\s* # optional colon or hyphen +(?P[<>=]+[^\s:\-]+) # version +\s*[:-]?\s* # optional colon or hyphen +(?P.*) # rest of line as comment text +/ix +REGEXP; + + private string $referenceVersion; + private bool $nonIgnorable; + + private VersionParser $versionParser; + + public function __construct(bool $nonIgnorable, string $referenceVersion) + { + $this->versionParser = new VersionParser(); + + $this->referenceVersion = $this->versionParser->normalize($referenceVersion); + $this->nonIgnorable = $nonIgnorable; + } + + 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; + + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node instanceof VirtualNode + || $node instanceof Node\Expr + ) { + // prevent duplicate errors + return []; + } + + $errors = []; + + foreach ($node->getComments() as $comment) { + + $text = $comment->getText(); + + /** + * PHP doc comments have the entire multi-line comment as the text. + * Since this could potentially contain multiple "todo" comments, we need to check all lines. + * This works for single line comments as well. + * + * PREG_OFFSET_CAPTURE: Track where each "todo" comment starts within the whole comment text. + * PREG_SET_ORDER: Make each value of $matches be structured the same as if from preg_match(). + */ + if (preg_match_all(self::PATTERN, $text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER) === FALSE) { + continue; + } + + /** @var array> $matches */ + foreach ($matches as $match) { + + $version = $match['version'][0]; + $todoText = trim($match['comment'][0]); + + $versionComparator = $this->getVersionComparator($version); + $plainVersion = ltrim($version, implode("", self::COMPARATORS)); + $normalized = $this->versionParser->normalize($plainVersion); + + $expired = false; + if ($versionComparator === '<') { + $expired = Comparator::greaterThanOrEqualTo($this->referenceVersion, $normalized); + } elseif ($versionComparator === '>') { + $expired = Comparator::greaterThan($this->referenceVersion, $normalized); + } + + if (!$expired) { + continue; + } + + // Have always present date at the start of the message. + // If there is further text, append it. + if ($todoText !== '') { + $errorMessage = "Version requirement {$version} not satisfied: {$todoText}"; + } else { + $errorMessage = "Version requirement {$version} not satisfied"; + } + + $wholeMatchStartOffset = $match[0][1]; + + // Count the number of newlines between the start of the whole comment, and the start of the match. + $newLines = substr_count($text, "\n", 0, $wholeMatchStartOffset); + + // Set the message line to match the line the comment actually starts on. + $messageLine = $comment->getStartLine() + $newLines; + + $errBuilder = RuleErrorBuilder::message($errorMessage)->line($messageLine); + if ($this->nonIgnorable) { + $errBuilder->nonIgnorable(); + } + $errors[] = $errBuilder->build(); + } + } + + return $errors; + } +} diff --git a/tests/TodoByVersionRuleTest.php b/tests/TodoByVersionRuleTest.php new file mode 100644 index 0000000..787dfa1 --- /dev/null +++ b/tests/TodoByVersionRuleTest.php @@ -0,0 +1,90 @@ + + */ +final class TodoByVersionRuleTest extends RuleTestCase +{ + private string $referenceVersion; + protected function getRule(): Rule + { + return new TodoByVersionRule(true, $this->referenceVersion); + } + + /** + * @param list $errors + * @dataProvider provideErrors + */ + public function testRule(string $referenceVersion, array $errors): void + { + $this->referenceVersion = $referenceVersion; + + $this->analyse([__DIR__ . '/data/version.php'], $errors); + } + + /** + * @return iterable}> + */ + static public function provideErrors(): iterable + { + yield [ + "0.1", + [ + ] + ]; + + yield [ + "1.0", + [ + [ + 'Version requirement <1.0.0 not satisfied: This has to be in the first major release', + 5, + ], + [ + 'Version requirement <1.0.0 not satisfied', + 10, + ] + ] + ]; + + yield [ + "123.4", + [ + [ + 'Version requirement <1.0.0 not satisfied: This has to be in the first major release', + 5, + ], + [ + 'Version requirement <1.0.0 not satisfied', + 10, + ] + ] + ]; + + yield [ + "123.5", + [ + [ + 'Version requirement <1.0.0 not satisfied: This has to be in the first major release', + 5, + ], + [ + 'Version requirement >123.4 not satisfied: Must fix this or bump the version', + 7, + ], + [ + 'Version requirement <1.0.0 not satisfied', + 10, + ] + ] + ]; + } + +} diff --git a/tests/data/version.php b/tests/data/version.php new file mode 100644 index 0000000..576355a --- /dev/null +++ b/tests/data/version.php @@ -0,0 +1,10 @@ +123.4: Must fix this or bump the version +} + +// TODO: <1.0.0