diff --git a/.github/workflows/all_tests.yml b/.github/workflows/all_tests.yml new file mode 100644 index 0000000..f23a0ed --- /dev/null +++ b/.github/workflows/all_tests.yml @@ -0,0 +1,47 @@ +name: "All Tests" + +on: + pull_request: + push: + +jobs: + test: + name: "Run all checks for all supported PHP versions" + + runs-on: "ubuntu-22.04" + + strategy: + fail-fast: false + matrix: + php-version: + - "8.0" + - "8.1" + - "8.2" + - "8.3" + + steps: + - name: "Checkout" + uses: "actions/checkout@v4" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + tools: composer + + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: "php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}" + restore-keys: "php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}" + + - name: "Install composer dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Run tests" + run: "composer tests" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03df99b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +composer.lock +.idea +.phpunit.cache diff --git a/README.md b/README.md index 0cedf4f..c3ad7ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,120 @@ -# rector-rule -RectorPHP rule to convert PHPDoc annotations for static analysis to PHP attributes +# PHP Static Analysis RectorPHP Rule +[![Continuous Integration](https://github.com/php-static-analysis/rector-rule/workflows/All%20Tests/badge.svg)](https://github.com/php-static-analysis/rector-rule/actions) +[![Latest Stable Version](https://poser.pugx.org/php-static-analysis/rector-rule/v/stable)](https://packagist.org/packages/php-static-analysis/rector-rule) +[![PHP Version Require](http://poser.pugx.org/php-static-analysis/rector-rule/require/php)](https://packagist.org/packages/php-static-analysis/rector-rule) +[![License](https://poser.pugx.org/php-static-analysis/rector-rule/license)](https://github.com/php-static-analysis/rector-rule/blob/main/LICENSE) +[![Total Downloads](https://poser.pugx.org/php-static-analysis/rector-rule/downloads)](https://packagist.org/packages/php-static-analysis/rector-rule/stats) + +Since the release of PHP 8.0 more and more libraries, frameworks and tools have been updated to use attributes instead of annotations in PHPDocs. + +However, static analysis tools like PHPStan have not made this transition to attributes and they still rely on annotations in PHPDocs for a lot of their functionality. + +This is a set of RectorPHP rules that allows us to convert standard PHP static analysis annotations into a new set of attributes that replace these annotations. These attributes are defined in [this repository](https://github.com/php-static-analysis/attributes) + +## Example + +In order to show how code would look with these attributes, we can look at the following example. This is how a class looks like with the current annotations: + +```php + */ + private array $result; + + /** + * @param array $array1 + * @param array $array2 + * @return array + */ + public function addArrays(array $array1, array $array2): array + { + $this->result = $array1 + $array2; + return $this->result; + } +} +``` + +And this is how it would look like using the new attributes: + +```php +')] + private array $result; + + #[Param(array1: 'array')] + #[Param(array2: 'array')] + #[Returns('array')] + public function addArrays(array $array1, array $array2): array + { + $this->array = $array1 + $array2; + return $this->array; + } +} +``` + +## Installation + +First of all, to make the attributes available for your codebase use: + +``` +composer require php-static-analysis/attributes +``` + +To use these rules, install this package: + +``` +composer require --dev php-static-analysis/rector-rule +``` + +## Using the rules + +To replace all the annotations that this package covers, use the set provided by it: + +```php +use Rector\Config\RectorConfig; +use PhpStaticAnalysis\RectorRule\Set\PhpStaticAnalysisSetList; + +return RectorConfig::configure() + ->withSets([ + PhpStaticAnalysisSetList::ANNOTATIONS_TO_ATTRIBUTES + ]); +``` + +If you only want to replace some annotations and leave the others as they are, use the rule configured with the annotations that you need. For example, if you only want to replace the `@return` and `@param` annotations, use this configuration: + +```php +use Rector\Config\RectorConfig; +use Rector\Php80\ValueObject\AnnotationToAttribute; +use PhpStaticAnalysis\Attributes\Param; +use PhpStaticAnalysis\Attributes\Returns; +use PhpStaticAnalysis\RectorRule\AnnotationsToAttributesRector; + +return RectorConfig::configure() + ->withConfiguredRule( + AnnotationsToAttributesRector::class, + [ + new AnnotationToAttribute('param', Param::class), + new AnnotationToAttribute('return', Returns::class), + ] + ); +``` + +These are the available attributes and their corresponding PHPDoc annotations: + +| Attribute | PHPDoc Annotation | +|---------------------------------------------------------------------------------------------|-------------------| +| [IsReadOnly](https://github.com/php-static-analysis/attributes/blob/main/doc/IsReadOnly.md) | `@readonly` | +| [Param](https://github.com/php-static-analysis/attributes/blob/main/doc/Param.md) | `@param` | +| [Returns](https://github.com/php-static-analysis/attributes/blob/main/doc/Returns.md) | `@return` | +| [Template](https://github.com/php-static-analysis/attributes/blob/main/doc/Template.md) | `@template` | +| [Type](https://github.com/php-static-analysis/attributes/blob/main/doc/Type.md) | `@var` | + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..697be4a --- /dev/null +++ b/composer.json @@ -0,0 +1,63 @@ +{ + "name": "php-static-analysis/rector-rule", + "description": "RectorPHP rule to convert PHPDoc annotations for static analysis to PHP attributes", + "type": "rector-extension", + "keywords": ["dev", "static analysis"], + "license": "MIT", + "autoload": { + "psr-4": { + "PhpStaticAnalysis\\RectorRule\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "test\\PhpStaticAnalysis\\RectorRule\\": "tests/" + } + }, + "authors": [ + { + "name": "Carlos Granados", + "email": "carlos@fastdebug.io" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.0", + "php-static-analysis/attributes": "^0.1 || dev-main", + "rector/rector": "^0.19 || ^1.0" + }, + "require-dev": { + "php-static-analysis/phpstan-extension": "dev-main", + "php-static-analysis/psalm-plugin": "dev-main", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.0", + "symplify/easy-coding-standard": "^12.1", + "vimeo/psalm": "^5", + "webmozart/assert": "^1.11" + }, + "scripts": { + "tests": [ + "@ecs", + "@psalm", + "@phpunit", + "@phpstan", + "@rector" + ], + "psalm": "psalm", + "ecs": "ecs", + "ecs-fix": "ecs --fix", + "phpunit": "phpunit", + "phpstan": "phpstan analyse", + "rector": "rector --dry-run", + "rector-fix": "rector", + "rector-debug": "rector --clear-cache --xdebug --dry-run" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + }, + "sort-packages": true + } +} diff --git a/config/sets/php-static-analysis-annotations-to-attributes.php b/config/sets/php-static-analysis-annotations-to-attributes.php new file mode 100644 index 0000000..aa5f2d9 --- /dev/null +++ b/config/sets/php-static-analysis-annotations-to-attributes.php @@ -0,0 +1,25 @@ +ruleWithConfiguration( + AnnotationsToAttributesRector::class, + [ + new AnnotationToAttribute('param', Param::class), + new AnnotationToAttribute('readonly', IsReadOnly::class), + new AnnotationToAttribute('return', Returns::class), + new AnnotationToAttribute('template', Template::class), + new AnnotationToAttribute('var', Type::class), + ] + ); +}; diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..6a518f6 --- /dev/null +++ b/ecs.php @@ -0,0 +1,15 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withPreparedSets( + psr12: true, + ); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3cd167e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + - tests + excludePaths: + - tests/Fixture/* diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ac6b490 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + tests + + + \ No newline at end of file diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..7de6547 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..6604636 --- /dev/null +++ b/rector.php @@ -0,0 +1,18 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withSkip([ + __DIR__ . '/tests/Fixture', + ]) + ->withSets([ + PhpStaticAnalysisSetList::ANNOTATIONS_TO_ATTRIBUTES + ]); diff --git a/src/AnnotationsToAttributesRector.php b/src/AnnotationsToAttributesRector.php new file mode 100644 index 0000000..038629d --- /dev/null +++ b/src/AnnotationsToAttributesRector.php @@ -0,0 +1,233 @@ + */ + private array $result; + + /** + * @param array $array1 + * @param array $array2 + * @return array + */ + public function addArrays(array $array1, array $array2): array + { + $this->result = $array1 + $array2; + return $this->result; + } +} +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +')] + private array $result; + + #[Param(array1: 'array')] + #[Param(array2: 'array')] + #[Returns('array')] + public function addArrays(array $array1, array $array2): array + { + $this->array = $array1 + $array2; + return $this->array; + } +} + +CODE_SAMPLE + ), + ]); + } + + /** + * @psalm-suppress MoreSpecificImplementedParamType + */ + #[Param(configuration: 'AnnotationToAttribute[]')] + public function configure(array $configuration): void + { + Assert::allIsAOf($configuration, AnnotationToAttribute::class); + $this->annotationsToAttributes = $configuration; + } + + #[Returns('array>')] + public function getNodeTypes(): array + { + return [ + Stmt\Class_::class, + Stmt\ClassConst::class, + Stmt\ClassMethod::class, + Stmt\Function_::class, + Stmt\Interface_::class, + Stmt\Property::class, + Stmt\Trait_::class + ]; + } + + /** + * @psalm-suppress MoreSpecificImplementedParamType + */ + #[Param(node: 'Stmt\Class_|Stmt\ClassConst|Stmt\ClassMethod|Stmt\Function_|Stmt\Interface_|Stmt\Property|Stmt\Trait_')] + public function refactor(Node $node): ?Node + { + $phpDocInfo = $this->phpDocInfoFactory->createFromNode($node); + if (!$phpDocInfo instanceof PhpDocInfo) { + return null; + } + + $attributeGroups = $this->processAnnotations($phpDocInfo); + + if ($attributeGroups === []) { + return null; + } + + $this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($node); + + $this->attributeGroupNamedArgumentManipulator->decorate($attributeGroups); + $node->attrGroups = array_merge($node->attrGroups, $attributeGroups); + + return $node; + } + + #[Returns('AttributeGroup[]')] + private function processAnnotations(PhpDocInfo $phpDocInfo): array + { + if ($phpDocInfo->getPhpDocNode()->children === []) { + return []; + } + + $attributeGroups = []; + $tagValueNodes = []; + + foreach ($phpDocInfo->getPhpDocNode()->children as $phpDocChildNode) { + if (!$phpDocChildNode instanceof PhpDocTagNode) { + continue; + } + + $tagValueNode = $phpDocChildNode->value; + switch (true) { + case $tagValueNode instanceof ParamTagValueNode: + $args = [ + new Node\Arg( + value: new Scalar\String_((string)($tagValueNode->type)), + name: new Node\Identifier(substr($tagValueNode->parameterName, 1)) + ) + ]; + break; + case $tagValueNode instanceof ReturnTagValueNode: + case $tagValueNode instanceof VarTagValueNode: + $args = [ + new Node\Arg(new Scalar\String_((string)($tagValueNode->type))) + ]; + break; + case $tagValueNode instanceof TemplateTagValueNode: + $args = [ + new Node\Arg(new Scalar\String_($tagValueNode->name)) + ]; + if ($tagValueNode->bound instanceof IdentifierTypeNode) { + $args[] = new Node\Arg(new Scalar\String_($tagValueNode->bound->name)); + } + break; + case $tagValueNode instanceof GenericTagValueNode: + $args = []; + break; + default: + continue 2; + } + + $annotationToAttribute = $this->matchAnnotationToAttribute($phpDocChildNode->name); + if (!$annotationToAttribute instanceof AnnotationToAttribute) { + continue; + } + + $tagValueNodes[] = $tagValueNode; + + $attributeName = new FullyQualified($annotationToAttribute->getAttributeClass()); + $attribute = new Attribute($attributeName, $args); + $attributeGroups[] = new AttributeGroup([$attribute]); + } + + foreach ($tagValueNodes as $tagValueNode) { + $this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $tagValueNode); + } + + return $attributeGroups; + } + + public function provideMinPhpVersion(): int + { + return PhpVersionFeature::ATTRIBUTES; + } + + private function matchAnnotationToAttribute( + string $tagName + ): AnnotationToAttribute|null { + foreach ($this->annotationsToAttributes as $annotationToAttribute) { + if ($tagName == '@' . $annotationToAttribute->getTag()) { + return $annotationToAttribute; + } + } + + return null; + } +} diff --git a/src/Set/PhpStaticAnalysisSetList.php b/src/Set/PhpStaticAnalysisSetList.php new file mode 100644 index 0000000..4ff5378 --- /dev/null +++ b/src/Set/PhpStaticAnalysisSetList.php @@ -0,0 +1,14 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured-rule.php'; + } +} diff --git a/tests/Fixture/IsReadOnlyAttributeTest.php.inc b/tests/Fixture/IsReadOnlyAttributeTest.php.inc new file mode 100644 index 0000000..5a4a4be --- /dev/null +++ b/tests/Fixture/IsReadOnlyAttributeTest.php.inc @@ -0,0 +1,51 @@ + +----- + diff --git a/tests/Fixture/ParamAttributeTest.php.inc b/tests/Fixture/ParamAttributeTest.php.inc new file mode 100644 index 0000000..6dbb74b --- /dev/null +++ b/tests/Fixture/ParamAttributeTest.php.inc @@ -0,0 +1,117 @@ + +----- + diff --git a/tests/Fixture/ReturnsAttributeTest.php.inc b/tests/Fixture/ReturnsAttributeTest.php.inc new file mode 100644 index 0000000..60e1ebc --- /dev/null +++ b/tests/Fixture/ReturnsAttributeTest.php.inc @@ -0,0 +1,83 @@ + +----- + diff --git a/tests/Fixture/TemplateAttributeTest.php.inc b/tests/Fixture/TemplateAttributeTest.php.inc new file mode 100644 index 0000000..e92aa39 --- /dev/null +++ b/tests/Fixture/TemplateAttributeTest.php.inc @@ -0,0 +1,101 @@ + +----- + diff --git a/tests/Fixture/TypeAttributeTest.php.inc b/tests/Fixture/TypeAttributeTest.php.inc new file mode 100644 index 0000000..aef1745 --- /dev/null +++ b/tests/Fixture/TypeAttributeTest.php.inc @@ -0,0 +1,51 @@ + +----- + diff --git a/tests/config/configured-rule.php b/tests/config/configured-rule.php new file mode 100644 index 0000000..905b07d --- /dev/null +++ b/tests/config/configured-rule.php @@ -0,0 +1,19 @@ +sets([ + PhpStaticAnalysisSetList::ANNOTATIONS_TO_ATTRIBUTES + ]); +};