From b32a0f38332e77177d7533d08c7607ee0a9b50bb Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Mon, 5 Feb 2024 10:02:17 +0100 Subject: [PATCH] Tag version 0.1.0 --- .github/workflows/all_tests.yml | 47 ++++ .gitignore | 4 + README.md | 122 ++++++++- composer.json | 63 +++++ ...tic-analysis-annotations-to-attributes.php | 25 ++ ecs.php | 15 ++ phpstan.neon | 7 + phpunit.xml | 19 ++ psalm.xml | 23 ++ rector.php | 18 ++ src/AnnotationsToAttributesRector.php | 233 ++++++++++++++++++ src/Set/PhpStaticAnalysisSetList.php | 14 ++ tests/AnnotationsToAttributesRectorTest.php | 29 +++ tests/Fixture/IsReadOnlyAttributeTest.php.inc | 51 ++++ tests/Fixture/ParamAttributeTest.php.inc | 117 +++++++++ tests/Fixture/ReturnsAttributeTest.php.inc | 83 +++++++ tests/Fixture/TemplateAttributeTest.php.inc | 101 ++++++++ tests/Fixture/TypeAttributeTest.php.inc | 51 ++++ tests/config/configured-rule.php | 19 ++ 19 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/all_tests.yml create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 config/sets/php-static-analysis-annotations-to-attributes.php create mode 100644 ecs.php create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 psalm.xml create mode 100644 rector.php create mode 100644 src/AnnotationsToAttributesRector.php create mode 100644 src/Set/PhpStaticAnalysisSetList.php create mode 100644 tests/AnnotationsToAttributesRectorTest.php create mode 100644 tests/Fixture/IsReadOnlyAttributeTest.php.inc create mode 100644 tests/Fixture/ParamAttributeTest.php.inc create mode 100644 tests/Fixture/ReturnsAttributeTest.php.inc create mode 100644 tests/Fixture/TemplateAttributeTest.php.inc create mode 100644 tests/Fixture/TypeAttributeTest.php.inc create mode 100644 tests/config/configured-rule.php 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 + ]); +};