diff --git a/README.md b/README.md index fc45c53..74c77b4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ function doFoo() { Every comment which matches the [supported pattern](https://github.com/staabm/phpstan-todo-by/blob/main/src/TodoByRule.php#L15) will be checked. -A todo comment can also consist of just a date without any text, like `// TODO 2023-12-14`. +A todo comment can also consist of just a date without any text, like `// @todo 2023-12-14`. When a text is given after the date, this text will be picked up for the PHPStan error message. The supported dateformat is `YYYY-MM-DD`. See [all supported examples](https://github.com/staabm/phpstan-todo-by/blob/main/tests/data/example.php) in the Testsuite. diff --git a/src/TodoByRule.php b/src/TodoByRule.php index b165f1e..cb09f4f 100644 --- a/src/TodoByRule.php +++ b/src/TodoByRule.php @@ -3,16 +3,32 @@ namespace staabm\PHPStanTodoBy; use PhpParser\Node; +use PHPStan\Analyser\Scope; use PHPStan\Node\VirtualNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use function preg_match_all; +use function strtotime; +use function substr_count; +use function time; +use function trim; +use const PREG_OFFSET_CAPTURE; +use const PREG_SET_ORDER; /** * @implements Rule */ final class TodoByRule implements Rule { - private const PATTERN = '/^TODO:?\s*([0-9]{4}-[0-9]{2}-[0-9]{2}):?(.*)$/'; + private const PATTERN = <<<'REGEXP' +/ +@?TODO # possible @ prefix +\s*[:-]?\s* # optional colon or hyphen +(?P\d{4}-\d{2}-\d{2}) # date consisting of YYYY-MM-DD format +\s*[:-]?\s* # optional colon or hyphen +(?P.*) # rest of line as comment text +/ix +REGEXP; private int $now; private bool $nonIgnorable; @@ -28,7 +44,7 @@ public function getNodeType(): string return Node::class; } - public function processNode(\PhpParser\Node $node, \PHPStan\Analyser\Scope $scope): array + public function processNode(Node $node, Scope $scope): array { if ( $node instanceof VirtualNode @@ -38,39 +54,62 @@ public function processNode(\PhpParser\Node $node, \PHPStan\Analyser\Scope $scop return []; } - $comments = $node->getComments(); - if (count($comments) === 0) { - return []; - } - $errors = []; - foreach($comments as $comment) { - $text = ltrim($comment->getText(), "\t /"); - if (!str_starts_with($text, 'TODO')) { - continue; - } - if (preg_match(self::PATTERN, $text, $matches) !== 1) { - continue; - } + foreach ($node->getComments() as $comment) { + + $text = $comment->getText(); - $date = $matches[1]; - $todoText = trim($matches[2]); - if (strtotime($date) > $this->now) { + /** + * 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; } - if ($todoText === '') { - $errorMessage = 'comment expired on '. $date .'.'; - } else { - $errorMessage = "comment '$todoText' expired on ". $date .'.'; - } + /** @var array> $matches */ + foreach ($matches as $match) { + + $date = $match['date'][0]; + $todoText = trim($match['comment'][0]); + + /** + * strtotime() will parse date-only values with time set to 00:00:00. + * This is fine, because this will count any expiration matching + * the current date as expired, except when ran exactly at 00:00:00. + */ + if (strtotime($date) > $this->now) { + continue; + } + + + // Have always present date at the start of the message. + // If there is further text, append it. + if ($todoText !== '') { + $errorMessage = "Expired on {$date}: {$todoText}"; + } else { + $errorMessage = "Comment expired on {$date}"; + } + + $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($comment->getStartLine()); - if ($this->nonIgnorable) { - $errBuilder->nonIgnorable(); + $errBuilder = RuleErrorBuilder::message($errorMessage)->line($messageLine); + if ($this->nonIgnorable) { + $errBuilder->nonIgnorable(); + } + $errors[] = $errBuilder->build(); } - $errors[] = $errBuilder->build(); } return $errors; diff --git a/tests/TodoByRuleTest.php b/tests/TodoByRuleTest.php index 03f5edc..a556ec8 100644 --- a/tests/TodoByRuleTest.php +++ b/tests/TodoByRuleTest.php @@ -20,44 +20,76 @@ public function testRule(): void { $this->analyse([__DIR__ . '/data/example.php'], [ [ - "comment 'Expired comment1' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired comment1', 9, ], [ - "comment 'Expired comment2' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired comment2', 10, ], [ - "comment 'Expired comment3' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired comment3', 11, ], [ - "comment 'Expired comment4' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired comment4', 12, ], [ - "comment 'Expired comment5' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired comment5', 13, ], [ - "comment 'Expired commentX' expired on 2023-12-14.", + 'Expired on 2023-12-14: Expired commentX', 19, ], [ - "comment expired on 2023-12-14.", + 'Comment expired on 2023-12-14', 21, ], [ - "comment 'method comment' expired on 2023-12-14.", - 27, + 'Expired on 2023-12-14: class comment', + 29, ], [ - "comment 'in method comment1' expired on 2023-12-14.", - 29, + 'Expired on 2023-12-13: class comment', + 30, + ], + [ + "Expired on 2023-12-14: method comment", + 34, + ], + [ + 'Expired on 2023-12-14: in method comment1', + 36, + ], + [ + 'Expired on 2023-12-14: in method comment2', + 38, + ], + [ + 'Expired on 2023-12-14: Convert to standard Drupal $content code.', + 43, + ], + [ + 'Expired on 2023-12-14: Decide to fix all the broken instances of class as a string', + 46, + ], + [ + 'Expired on 2023-12-14: fix it', + 48, + ], + [ + 'Expired on 2023-12-14: fix it', + 49, + ], + [ + 'Expired on 2023-12-14: fix it', + 50, ], [ - "comment 'in method comment2' expired on 2023-12-14.", - 31, + 'Expired on 2023-12-14: fix it', + 51, ], ]); } diff --git a/tests/data/example.php b/tests/data/example.php index 21f3e7c..ef9dd1e 100644 --- a/tests/data/example.php +++ b/tests/data/example.php @@ -23,6 +23,13 @@ function doFooBar():void { } +/** + * other text + * + * @todo 2023-12-14 class comment + * @TODO 2023-12-13 - class comment + * more comment data + */ class Z { // TODO: 2023-12-14 method comment public function XY():void { @@ -31,3 +38,14 @@ public function XY():void { // TODO 2023-12-14: in method comment2 } } + +/** + * @todo 2023-12-14 - Convert to standard Drupal $content code. + */ + +// @todo 2023-12-14 Decide to fix all the broken instances of class as a string + +// @todo: 2023-12-14 fix it +// @todo 2023-12-14: fix it +// todo - 2023-12-14 fix it +// todo 2023-12-14 - fix it