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 c40c1a0..fb4b167 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ -# phpstan-extension -PHPStan extension to read static analysis attributes +# PHP Static Analysis Attributes PHPStan Extension +[![Continuous Integration](https://github.com/php-static-analysis/phpstan-extension/workflows/All%20Tests/badge.svg)](https://github.com/php-static-analysis/phpstan-extension/actions) +[![Latest Stable Version](https://poser.pugx.org/php-static-analysis/phpstan-extension/v/stable)](https://packagist.org/packages/php-static-analysis/phpstan-extension) +[![PHP Version Require](http://poser.pugx.org/php-static-analysis/phpstan-extension/require/php)](https://packagist.org/packages/php-static-analysis/phpstan-extension) +[![License](https://poser.pugx.org/php-static-analysis/phpstan-extension/license)](https://github.com/php-static-analysis/phpstan-extension/blob/main/LICENSE) +[![Total Downloads](https://poser.pugx.org/php-static-analysis/phpstan-extension/downloads)](https://packagist.org/packages/php-static-analysis/phpstan-extension/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 PHPStan extension that allows PHPStan to understand a new set of attributes that replace the PHPDoc 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 this extension, require it in Composer: + +``` +composer require --dev php-static-analysis/phpstan-extension +``` + +If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer) then you're all set! + +
+ Manual installation + +If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config: + +``` +includes: + - vendor/php-static-analysis/phpstan-extension/extension.neon +``` +
+ +## Using the extension + +This extension works by interacting with the parser that PHPStan uses to parse the code and replacing the new Attributes with PHPDoc annotations that PHPStan can understand. The functionality provided by the attribute is exactly the same as the one provided by the corresponding PHPDoc annotation. + +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..8d0c8d1 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "php-static-analysis/phpstan-extension", + "description": "PHPStan extension to read static analysis attributes", + "type": "phpstan-extension", + "keywords": ["dev", "static analysis"], + "license": "MIT", + "autoload": { + "psr-4": { + "PhpStaticAnalysis\\PHPStanExtension\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "test\\PhpStaticAnalysis\\PHPStanExtension\\": "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", + "php-static-analysis/node-visitor": "^0.1 || dev-main", + "phpstan/phpstan": "^1.8" + }, + "require-dev": { + "php-static-analysis/psalm-plugin": "dev-main", + "phpunit/phpunit": "^9.0", + "symplify/easy-coding-standard": "^12.1", + "vimeo/psalm": "^5" + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "phpstan": "phpstan analyse", + "phpstan-debug": "phpstan analyse --xdebug --debug", + "ecs": "ecs", + "ecs-fix": "ecs --fix", + "phpunit": "phpunit", + "psalm": "psalm", + "tests": [ + "@ecs", + "@phpstan", + "@phpunit", + "@psalm" + ] + } +} 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/extension.neon b/extension.neon new file mode 100644 index 0000000..d483c75 --- /dev/null +++ b/extension.neon @@ -0,0 +1,13 @@ +services: + attributeParser: + class: PhpStaticAnalysis\PHPStanExtension\Parser\AttributeParser + arguments: + parser: @pathRoutingParser + autowired: false + + defaultAnalysisParser: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @attributeParser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + autowired: false diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3f21a8f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,18 @@ +includes: + - extension.neon + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: max + paths: + - src + - tests + excludePaths: + - tests/data/* + ignoreErrors: + - + message: '#^Accessing [a-zA-Z\\]+::class is not covered by backward compatibility promise. The class might change in a minor PHPStan version.$#' + path: tests/BaseAttributeTestCase.php + - + message: '#^Calling [a-zA-Z\\]+::[a-zA-Z]+\(\) is not covered by backward compatibility promise. The method might change in a minor PHPStan version.$#' + path: tests/BaseAttributeTestCase.php 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..55a8ab4 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/src/Parser/AttributeParser.php b/src/Parser/AttributeParser.php new file mode 100644 index 0000000..7b49a23 --- /dev/null +++ b/src/Parser/AttributeParser.php @@ -0,0 +1,45 @@ +parser->parseFile($file); + return $this->traverseAst($ast); + } + + public function parseString(string $sourceCode): array + { + $ast = $this->parser->parseString($sourceCode); + return $this->traverseAst($ast); + } + + #[Param(ast: 'Stmt[]')] + #[Returns('Stmt[]')] + private function traverseAst(array $ast): array + { + $traverser = new NodeTraverser(); + $nodeVisitor = new AttributeNodeVisitor(); + $traverser->addVisitor($nodeVisitor); + + $ast = $traverser->traverse($ast); + /** @var Stmt[] $ast */ + return $ast; + } +} diff --git a/tests/BaseAttributeTestCase.php b/tests/BaseAttributeTestCase.php new file mode 100644 index 0000000..c13deca --- /dev/null +++ b/tests/BaseAttributeTestCase.php @@ -0,0 +1,52 @@ +getFileHelper()->normalizePath($file); + $analyser = self::getContainer()->getByType(Analyser::class); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $errors = $analyser->analyse([$file], null, null, true)->getErrors(); + foreach ($errors as $error) { + $this->assertSame($fileHelper->normalizePath($file), $error->getFilePath()); + } + + return $errors; + } + + #[Param( + errors: 'Error[]', + expectedErrors: 'array', + )] + protected function checkExpectedErrors( + array $errors, + array $expectedErrors + ): void { + $this->assertCount(count($expectedErrors), $errors); + + $errorNum = 0; + foreach ($expectedErrors as $error => $line) { + $this->assertSame($error, $errors[$errorNum]->getMessage()); + $this->assertSame($line, $errors[$errorNum]->getLine()); + $errorNum++; + } + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../extension.neon', + ]; + } +} diff --git a/tests/IsReadOnlyAttributeTest.php b/tests/IsReadOnlyAttributeTest.php new file mode 100644 index 0000000..c0c6717 --- /dev/null +++ b/tests/IsReadOnlyAttributeTest.php @@ -0,0 +1,39 @@ +analyse(__DIR__ . '/data/PropertyIsReadOnlyAttribute.php'); + $expectedErrors = [ + '@readonly property test\PhpStaticAnalysis\PHPStanExtension\data\PropertyIsReadOnlyAttribute::$name is assigned outside of its declaring class.' => 19, + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } + + public function testInvalidPropertyIsReadOnlyAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InvalidPropertyIsReadOnlyAttribute.php'); + + $expectedErrors = [ + 'Attribute class PhpStaticAnalysis\Attributes\IsReadOnly constructor invoked with 1 parameter, 0 required.' => 9, + 'Attribute class PhpStaticAnalysis\Attributes\IsReadOnly is not repeatable but is already present above the property.' => 13, + 'Attribute class PhpStaticAnalysis\Attributes\IsReadOnly does not have the method target.' => 16, + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/conf/readonly.neon', + ] + ); + } +} diff --git a/tests/ParamAttributeTest.php b/tests/ParamAttributeTest.php new file mode 100644 index 0000000..9b5792f --- /dev/null +++ b/tests/ParamAttributeTest.php @@ -0,0 +1,33 @@ +analyse(__DIR__ . '/data/MethodParamAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testFunctionParamAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/FunctionParamAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testInvalidMethodReturnsAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InvalidMethodParamAttribute.php'); + + $expectedErrors = [ + 'PHPDoc tag @param has invalid value (): Unexpected token "\n ", expected type at offset 13' => 9, + 'Parameter #1 ...$params of attribute class PhpStaticAnalysis\Attributes\Param constructor expects string, int given.' => 9, + 'PHPDoc tag @param has invalid value (string): Unexpected token "\n ", expected variable at offset 20' => 15, + 'PHPDoc tag @param has invalid value (count($a) $name): Unexpected token "(", expected variable at offset 19' => 21, + 'Attribute class PhpStaticAnalysis\Attributes\Param does not have the property target.' => 27, + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } +} diff --git a/tests/ReturnsAttributeTest.php b/tests/ReturnsAttributeTest.php new file mode 100644 index 0000000..1d0eab9 --- /dev/null +++ b/tests/ReturnsAttributeTest.php @@ -0,0 +1,34 @@ +analyse(__DIR__ . '/data/MethodReturnsAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testFunctionReturnsAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/FunctionReturnsAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testInvalidMethodReturnsAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InvalidMethodReturnsAttribute.php'); + + $expectedErrors = [ + 'PHPDoc tag @return has invalid value (): Unexpected token "\n ", expected type at offset 14' => 9, + 'Parameter #1 $type of attribute class PhpStaticAnalysis\Attributes\Returns constructor expects string, int given.' => 9, + 'Attribute class PhpStaticAnalysis\Attributes\Returns is not repeatable but is already present above the method.' => 16, + 'Attribute class PhpStaticAnalysis\Attributes\Returns constructor invoked with 2 parameters, 1 required.' => 22, + 'PHPDoc tag @return has invalid value ($a + $b): Unexpected token "$a", expected type at offset 15' => 28, + 'Attribute class PhpStaticAnalysis\Attributes\Returns does not have the property target.' => 34, + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } +} diff --git a/tests/TemplateAttributeTest.php b/tests/TemplateAttributeTest.php new file mode 100644 index 0000000..c6885c5 --- /dev/null +++ b/tests/TemplateAttributeTest.php @@ -0,0 +1,51 @@ +analyse(__DIR__ . '/data/ClassTemplateAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testTraitTemplateAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/TraitTemplateAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testInterfaceTemplateAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InterfaceTemplateAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testMethodTemplateAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/MethodTemplateAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testFunctionTemplateAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/FunctionTemplateAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testInvalidMethodTemplateAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InvalidMethodTemplateAttribute.php'); + + $expectedErrors = [ + 'PHPDoc tag @template has invalid value (): Unexpected token "\n ", expected type at offset 16' => 11, + 'Parameter #1 $name of attribute class PhpStaticAnalysis\Attributes\Template constructor expects string, int given.' => 11, + 'PHPDoc tag @template has invalid value (+5): Unexpected token "+5", expected type at offset 17' => 17, + 'Attribute class PhpStaticAnalysis\Attributes\Template does not have the property target.' => 23, + 'Parameter #2 $of of attribute class PhpStaticAnalysis\Attributes\Template constructor expects string|null, int given.' => 26 + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } +} diff --git a/tests/TypeAttributeTest.php b/tests/TypeAttributeTest.php new file mode 100644 index 0000000..2cdff7e --- /dev/null +++ b/tests/TypeAttributeTest.php @@ -0,0 +1,28 @@ +analyse(__DIR__ . '/data/PropertyTypeAttribute.php'); + $this->assertCount(0, $errors); + } + + public function testInvalidPropertyTypeAttribute(): void + { + $errors = $this->analyse(__DIR__ . '/data/InvalidPropertyTypeAttribute.php'); + + $expectedErrors = [ + 'PHPDoc tag @var has invalid value (): Unexpected token "\n ", expected type at offset 11' => 9, + 'Parameter #1 $type of attribute class PhpStaticAnalysis\Attributes\Type constructor expects string, int given.' => 9, + 'Attribute class PhpStaticAnalysis\Attributes\Type is not repeatable but is already present above the property.' => 13, + 'Attribute class PhpStaticAnalysis\Attributes\Type constructor invoked with 2 parameters, 1 required.' => 16, + 'PHPDoc tag @var has invalid value ($a + $b): Unexpected token "$a", expected type at offset 12' => 19, + 'Attribute class PhpStaticAnalysis\Attributes\Type does not have the method target.' => 22, + ]; + + $this->checkExpectedErrors($errors, $expectedErrors); + } +} diff --git a/tests/conf/readonly.neon b/tests/conf/readonly.neon new file mode 100644 index 0000000..8f76a1a --- /dev/null +++ b/tests/conf/readonly.neon @@ -0,0 +1,3 @@ +parameters: + featureToggles: + readOnlyByPhpDoc: true diff --git a/tests/data/ClassTemplateAttribute.php b/tests/data/ClassTemplateAttribute.php new file mode 100644 index 0000000..94f40ad --- /dev/null +++ b/tests/data/ClassTemplateAttribute.php @@ -0,0 +1,18 @@ +invalidProperty = 'Mike'; + $this->otherInvalidProperty = 'John'; + } +} diff --git a/tests/data/InvalidPropertyTypeAttribute.php b/tests/data/InvalidPropertyTypeAttribute.php new file mode 100644 index 0000000..8501bde --- /dev/null +++ b/tests/data/InvalidPropertyTypeAttribute.php @@ -0,0 +1,27 @@ +name = 'Mike'; + } +} + +$p = new PropertyIsReadOnlyAttribute(); +$p->name = 'John'; diff --git a/tests/data/PropertyTypeAttribute.php b/tests/data/PropertyTypeAttribute.php new file mode 100644 index 0000000..e7d2e74 --- /dev/null +++ b/tests/data/PropertyTypeAttribute.php @@ -0,0 +1,19 @@ +