Skip to content

Commit

Permalink
Implement TodoByVersionRule (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
staabm authored Dec 18, 2023
1 parent a70de60 commit b045f3c
Show file tree
Hide file tree
Showing 11 changed files with 409 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/.github export-ignore
/tests export-ignore
.editorconfig export-ignore
phpstan.neon export-ignore
3 changes: 3 additions & 0 deletions .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Get tags
run: git fetch --tags origin

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Get tags
run: git fetch --tags origin

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
Expand Down
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# phpstan-todo-by: comments with expiration date
# phpstan-todo-by: comments with expiration date/version

PHPStan extension to check for TODO comments with expiration date.
PHPStan extension to check for TODO comments with expiration date/version.
Inspired by [parker-codes/todo-by](https://github.com/parker-codes/todo_by).

## Example:
Expand All @@ -13,6 +13,11 @@ function doFoo() {

}

// TODO: <1.0.0 This has to be in the first major release
function doBar() {

}

```

## Supported todo formats
Expand Down Expand Up @@ -45,6 +50,9 @@ examples supported as of version 0.1.5:
* @todo 2023-12-14 classic multi line comment
* more comment data
*/

// TODO: <1.0.0 This has to be in the first major release
// TODO >123.4: Must fix this or bump the version
```

## Configuration
Expand All @@ -62,14 +70,15 @@ parameters:

### Reference time

By default comments are checked against todays date.
By default date-todo-comments are checked against todays date.

You might be interested, which comments will expire e.g. within the next 7 days, which can be configured with the `referenceTime` option.
You need to configure a date parsable by `strtotime`.

```neon
parameters:
todo_by:
# any strtotime() compatible string
referenceTime: "now+7days"
```

Expand All @@ -83,6 +92,35 @@ parameters:

`TODOBY_REF_TIME="now+7days" vendor/bin/phpstan analyze`

### Reference version

By default version-todo-comments are checked against `"nextMajor"` version.
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.
Possible values are `"nextMajor"`, `"nextMinor"`, `"nextPatch"` - which will be computed based on the latest local git tag - or any other version string like `"1.2.3"`.

```neon
parameters:
todo_by:
# "nextMajor", "nextMinor", "nextPatch" or a version string like "1.2.3"
referenceVersion: "nextMinor"
```

As shown in the "Reference time"-paragraph above, you might even use a env variable instead.

Make sure tags are available within your git clone, e.g. by running `git fetch --tags origin`.

In a GitHub Action this can be done like this:

```yaml
- name: Checkout
uses: actions/checkout@v4

- name: Get tags
run: git fetch --tags origin
```
## Installation
To use this extension, require it in [Composer](https://getcomposer.org/):
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"keywords": ["dev", "phpstan", "phpstan-extension", "static analysis"],

"require": {
"php": "^7.4 || ^8.0"
"php": "^7.4 || ^8.0",
"composer/semver": "^3.4",
"nikolaposa/version": "^4.1"
},

"require-dev": {
Expand Down
20 changes: 20 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,38 @@ parametersSchema:
todo_by: structure([
nonIgnorable: bool()
referenceTime: string()
referenceVersion: string()
])

# default parameters
parameters:
todo_by:
nonIgnorable: true

# any strtotime() compatible string
referenceTime: "now"

# "nextMajor", "nextMinor", "nextPatch" or a version string like "1.2.3"
referenceVersion: "nextMajor"

services:
-
class: staabm\PHPStanTodoBy\TodoByDateRule
tags: [phpstan.rules.rule]
arguments:
- %todo_by.nonIgnorable%
- %todo_by.referenceTime%

-
class: staabm\PHPStanTodoBy\TodoByVersionRule
tags: [phpstan.rules.rule]
arguments:
- %todo_by.nonIgnorable%

-
class: staabm\PHPStanTodoBy\GitTagFetcher

-
class: staabm\PHPStanTodoBy\ReferenceVersionFinder
arguments:
- %todo_by.referenceVersion%
15 changes: 15 additions & 0 deletions src/GitTagFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace staabm\PHPStanTodoBy;

final class GitTagFetcher {
// fetch version of the latest created git tag
public function fetchLatestTagVersion(): string
{
exec('git for-each-ref --sort=-creatordate --count 1 --format="%(refname:short)" "refs/tags/"', $output, $returnCode);
if ($returnCode !== 0 || count($output) !== 1) {
throw new \RuntimeException('Could not determine latest git tag');
}
return $output[0];
}
}
34 changes: 34 additions & 0 deletions src/ReferenceVersionFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace staabm\PHPStanTodoBy;

use Version\Version;

final class ReferenceVersionFinder {
private GitTagFetcher $fetcher;
private string $referenceVersion;

public function __construct(string $referenceVersion, GitTagFetcher $fetcher) {
$this->referenceVersion = $referenceVersion;
$this->fetcher = $fetcher;
}
public function find(): string {
if (in_array($this->referenceVersion, ['nextMajor', 'nextMinor', 'nextPatch'], true)) {
$latestTagVersion = $this->fetcher->fetchLatestTagVersion();

$version = Version::fromString($latestTagVersion);
if ($this->referenceVersion === 'nextMajor') {
return $version->incrementMajor()->toString();
}
if ($this->referenceVersion === 'nextMinor') {
return $version->incrementMinor()->toString();
}
if ($this->referenceVersion === 'nextPatch') {
return $version->incrementPatch()->toString();
}
}

// a version string like "1.2.3"
return $this->referenceVersion;
}
}
156 changes: 156 additions & 0 deletions src/TodoByVersionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace staabm\PHPStanTodoBy;

use Composer\Semver\Comparator;
use Composer\Semver\VersionParser;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\VirtualNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
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<Node>
*/
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<version>[<>=]+[^\s:\-]+) # version
\s*[:-]?\s* # optional colon or hyphen
(?P<comment>.*) # rest of line as comment text
/ix
REGEXP;

private ?string $referenceVersion = null;
private bool $nonIgnorable;

private VersionParser $versionParser;

private ReferenceVersionFinder $referenceVersionFinder;

public function __construct(bool $nonIgnorable, ReferenceVersionFinder $refVersionFinder)
{
$this->versionParser = new VersionParser();
$this->referenceVersionFinder = $refVersionFinder;
$this->nonIgnorable = $nonIgnorable;
}

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;
}

$referenceVersion = $this->getReferenceVersion();

/** @var array<int, array<array{0: string, 1: int}>> $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($referenceVersion, $normalized);
} elseif ($versionComparator === '>') {
$expired = Comparator::greaterThan($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: ". rtrim($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;
}

private function getReferenceVersion(): string {
if ($this->referenceVersion === null) {
// lazy get the version, as it might incur subprocess creation
$this->referenceVersion = $this->versionParser->normalize($this->referenceVersionFinder->find());
}
return $this->referenceVersion;
}

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;

}
}
Loading

0 comments on commit b045f3c

Please sign in to comment.