From 17c82a32d70c9706a8f51b6480ea8e0ebdfdfc7b Mon Sep 17 00:00:00 2001 From: Emmanuel Averty Date: Wed, 17 Dec 2025 11:47:29 +0100 Subject: [PATCH] feat: keyword matching --- README.md | 16 +++- extension.neon | 10 +++ src/TodoByKeywordRule.php | 88 +++++++++++++++++++++ tests/TodoByKeywordRuleTest.php | 135 ++++++++++++++++++++++++++++++++ tests/data/keyword.php | 62 +++++++++++++++ 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/TodoByKeywordRule.php create mode 100644 tests/TodoByKeywordRuleTest.php create mode 100644 tests/data/keyword.php diff --git a/README.md b/README.md index 52d0baf..bfa8053 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Inspired by [parker-codes/todo-by](https://github.com/parker-codes/todo_by). ## Examples -The main idea is, that comments within the source code will be turned into PHPStan errors when a condition is satisfied, e.g. a date reached, a version met, a issue tracker ticket is closed. +The main idea is, that comments within the source code will be turned into PHPStan errors when a condition is satisfied, e.g. a date reached, a keyword, a version met, a issue tracker ticket is closed. ```php 123.4: Must fix this or bump the version @@ -124,6 +129,15 @@ parameters: `TODOBY_REF_TIME="now+7days" vendor/bin/phpstan analyze` +### Keywords + +A keyword list can be defined to create a PHPStan error each time one of these keywords is met after todo. + +```neon +parameters: + todo_by: + keywords: ["asap", "now"] +``` ### Reference version diff --git a/extension.neon b/extension.neon index bab3845..fa36cab 100644 --- a/extension.neon +++ b/extension.neon @@ -2,6 +2,7 @@ parametersSchema: todo_by: structure([ nonIgnorable: bool() referenceTime: string() + keywords: listOf(string()) referenceVersion: string() singleGitRepo: bool() virtualPackages: arrayOf(string(), string()) @@ -37,6 +38,9 @@ parameters: # any strtotime() compatible string referenceTime: "now" + # list of keywords to create an error if one is met after todo + keywords: [] + # "nextMajor", "nextMinor", "nextPatch" or a version string like "1.2.3" referenceVersion: "nextMajor" @@ -120,6 +124,12 @@ services: arguments: - %todo_by.referenceTime% + - + class: staabm\PHPStanTodoBy\TodoByKeywordRule + tags: [phpstan.rules.rule] + arguments: + - %todo_by.keywords% + - class: staabm\PHPStanTodoBy\TodoByTicketRule diff --git a/src/TodoByKeywordRule.php b/src/TodoByKeywordRule.php new file mode 100644 index 0000000..125f869 --- /dev/null +++ b/src/TodoByKeywordRule.php @@ -0,0 +1,88 @@ + + */ +final class TodoByKeywordRule implements Rule +{ + private const ERROR_IDENTIFIER = 'keyword'; + + private array $keywords; + + private const PATTERN = <<<'REGEXP' + { + @?(?:TODO|FIXME|XXX) # possible @ prefix + @?[a-zA-Z0-9_-]* # optional username + \s*[:-]?\s* # optional colon or hyphen + \s+ # separator + (?P(?:%s)) # keyword + \s*[:-]?\s* # optional colon or hyphen + (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end + }ix + REGEXP; + + private ExpiredCommentErrorBuilder $errorBuilder; + + /** + * @param list $keywords + */ + public function __construct( + array $keywords, + ExpiredCommentErrorBuilder $errorBuilder + ) { + $this->keywords = $keywords; + $this->errorBuilder = $errorBuilder; + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ([] === $this->keywords) { + return []; + } + + $it = CommentMatcher::matchComments($node, sprintf(self::PATTERN, implode('|', $this->keywords))); + + $errors = []; + foreach ($it as $comment => $matches) { + /** @var array> $matches */ + foreach ($matches as $match) { + $keyword = $match['keyword'][0]; + $todoText = trim($match['comment'][0]); + + // Have a keyword at the start of the message. + // If there is further text, append it. + if ('' !== $todoText) { + $errorMessage = "Should be processed ({$keyword}): ". rtrim($todoText, '.') .'.'; + } else { + $errorMessage = "Comment should be processed ({$keyword})."; + } + + $errors[] = $this->errorBuilder->buildError( + $comment->getText(), + $comment->getStartLine(), + $errorMessage, + self::ERROR_IDENTIFIER, + null, + $match[0][1] + ); + } + } + + return $errors; + } +} diff --git a/tests/TodoByKeywordRuleTest.php b/tests/TodoByKeywordRuleTest.php new file mode 100644 index 0000000..4a331f5 --- /dev/null +++ b/tests/TodoByKeywordRuleTest.php @@ -0,0 +1,135 @@ + + * @internal + */ +final class TodoByKeywordRuleTest extends RuleTestCase +{ + private array $keywords; + + protected function getRule(): Rule + { + return new TodoByKeywordRule( + $this->keywords, + new ExpiredCommentErrorBuilder(true), + ); + } + + public function testRule(): void + { + $this->keywords = [ + 'keyword1', + 'keyword2', + ]; + + $this->analyse([__DIR__ . '/data/keyword.php'], [ + [ + 'Should be processed (keyword1): comment1.', + 9, + ], + [ + 'Should be processed (keyword2): comment2.', + 10, + ], + [ + 'Should be processed (kEywORd1): comment3.', + 11, + ], + [ + 'Should be processed (keyword1): comment4.', + 12, + ], + [ + 'Should be processed (keyword1): comment5.', + 13, + ], + [ + 'Should be processed (keyword1): comment6.', + 14, + ], + [ + 'Should be processed (keyword1): comment7.', + 15, + ], + [ + 'Should be processed (keyword1): commentX.', + 20, + ], + [ + 'Comment should be processed (keyword1).', + 22, + ], + [ + 'Should be processed (keyword1): class comment.', + 30, + ], + [ + 'Should be processed (keyword1): class comment.', + 31, + ], + [ + 'Should be processed (keyword1): method comment.', + 35, + ], + [ + 'Should be processed (keyword1): in method comment1.', + 37, + ], + [ + 'Should be processed (keyword1): in method comment2.', + 39, + ], + [ + 'Should be processed (keyword1): Convert to standard Drupal $content code.', + 44, + ], + [ + 'Should be processed (keyword1): Decide to fix all the broken instances of class as a string.', + 47, + ], + [ + 'Should be processed (keyword1): fix it.', + 49, + ], + [ + 'Should be processed (keyword1): fix it.', + 50, + ], + [ + 'Should be processed (keyword1): fix it.', + 51, + ], + [ + 'Should be processed (keyword1): fix it.', + 52, + ], + [ + 'Should be processed (keyword1): fix it.', + 54, + ], + [ + 'Should be processed (keyword1): fix it.', + 55, + ], + [ + 'Should be processed (keyword1): classic multi line comment.', + 60, + ], + ]); + } + + public function testNoKeywordRule(): void + { + $this->keywords = []; + + $this->analyse([__DIR__ . '/data/keyword.php'], []); + } +} diff --git a/tests/data/keyword.php b/tests/data/keyword.php new file mode 100644 index 0000000..576ed38 --- /dev/null +++ b/tests/data/keyword.php @@ -0,0 +1,62 @@ +