diff --git a/.gitattributes b/.gitattributes index 615bf05f..aae3fd19 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,12 +2,10 @@ *.stub linguist-language=PHP *.neon linguist-language=YAML -.github export-ignore -tests export-ignore -tmp export-ignore -.gitattributes export-ignore -.gitignore export-ignore -Makefile export-ignore -phpcs.xml export-ignore -phpstan.neon export-ignore -phpunit.xml export-ignore +/.* export-ignore +/tests export-ignore +/tmp export-ignore +/Makefile export-ignore +/phpstan.neon export-ignore +/phpstan-baseline.neon export-ignore +/phpunit.xml export-ignore diff --git a/.github/renovate.json b/.github/renovate.json index b775cc18..d3f5961e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -10,11 +10,6 @@ "enabled": true, "groupName": "root-composer" }, - { - "matchPaths": ["build-cs/**"], - "enabled": true, - "groupName": "build-cs" - }, { "matchPaths": [".github/**"], "enabled": true, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82332bb6..bc327e8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "1.1.x" + - "1.3.x" jobs: lint: @@ -21,6 +21,7 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" steps: - name: "Checkout" @@ -45,7 +46,7 @@ jobs: - name: "Lint" run: "make lint" - coding-standards: + coding-standard: name: "Coding Standard" runs-on: "ubuntu-latest" @@ -54,11 +55,17 @@ jobs: - name: "Checkout" uses: actions/checkout@v3 + - name: "Checkout build-cs" + uses: actions/checkout@v3 + with: + repository: "phpstan/build-cs" + path: "build-cs" + - name: "Install PHP" uses: "shivammathur/setup-php@v2" with: coverage: "none" - php-version: "8.0" + php-version: "8.2" - name: "Validate Composer" run: "composer validate" @@ -66,6 +73,10 @@ jobs: - name: "Install dependencies" run: "composer install --no-interaction --no-progress" + - name: "Install build-cs dependencies" + working-directory: "build-cs" + run: "composer install --no-interaction --no-progress" + - name: "Lint" run: "make lint" @@ -85,6 +96,7 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" dependencies: - "lowest" - "highest" @@ -127,6 +139,7 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" dependencies: - "lowest" - "highest" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 00000000..8452d986 --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,53 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Create tag" + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + workflow_dispatch: + inputs: + version: + description: 'Next version' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + +jobs: + create-tag: + name: "Create tag" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout" + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: 'Get Previous tag' + id: previoustag + uses: "WyriHaximus/github-action-get-previous-tag@v1" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: 'Get next versions' + id: semvers + uses: "WyriHaximus/github-action-next-semvers@v1" + with: + version: ${{ steps.previoustag.outputs.tag }} + + - name: "Create new minor tag" + uses: rickstaa/action-create-tag@v1 + if: inputs.version == 'minor' + with: + tag: ${{ steps.semvers.outputs.minor }} + message: ${{ steps.semvers.outputs.minor }} + + - name: "Create new patch tag" + uses: rickstaa/action-create-tag@v1 + if: inputs.version == 'patch' + with: + tag: ${{ steps.semvers.outputs.patch }} + message: ${{ steps.semvers.outputs.patch }} diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index a05d4173..4c7990df 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -8,7 +8,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v3 + - uses: dessant/lock-threads@v4 with: github-token: ${{ github.token }} issue-inactive-days: '31' diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml new file mode 100644 index 00000000..6a1c8156 --- /dev/null +++ b/.github/workflows/release-toot.yml @@ -0,0 +1,21 @@ +name: Toot release + +# More triggers +# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release +on: + release: + types: [published] + +jobs: + toot: + runs-on: ubuntu-latest + steps: + - uses: cbrgm/mastodon-github-action@v1 + if: ${{ !github.event.repository.private }} + with: + # GitHub event payload + # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release + message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan" + env: + MASTODON_URL: https://phpc.social + MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fed0458..92b72547 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v3.0.0 + uses: metcalfc/changelog-generator@v4.1.0 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 2db21315..7de9f3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /tests/tmp +/build-cs /vendor /composer.lock .phpunit.result.cache diff --git a/Makefile b/Makefile index fe917d3b..ecd8cfb2 100644 --- a/Makefile +++ b/Makefile @@ -10,14 +10,24 @@ lint: php vendor/bin/parallel-lint --colors \ src tests +.PHONY: cs-install +cs-install: + git clone https://github.com/phpstan/build-cs.git || true + git -C build-cs fetch origin && git -C build-cs reset --hard origin/main + composer install --working-dir build-cs + .PHONY: cs cs: - composer install --working-dir build-cs && php build-cs/vendor/bin/phpcs + php build-cs/vendor/bin/phpcs --standard=build-cs/phpcs.xml src tests .PHONY: cs-fix cs-fix: - php build-cs/vendor/bin/phpcbf + php build-cs/vendor/bin/phpcbf --standard=build-cs/phpcs.xml src tests .PHONY: phpstan phpstan: php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests + +.PHONY: phpstan-generate-baseline +phpstan-generate-baseline: + php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests -b phpstan-baseline.neon diff --git a/build-cs/.gitignore b/build-cs/.gitignore deleted file mode 100644 index 61ead866..00000000 --- a/build-cs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/vendor diff --git a/build-cs/composer.json b/build-cs/composer.json deleted file mode 100644 index e3079710..00000000 --- a/build-cs/composer.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "require-dev": { - "consistence-community/coding-standard": "^3.10", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "slevomat/coding-standard": "^7.0" - }, - "config": { - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } - } -} diff --git a/build-cs/composer.lock b/build-cs/composer.lock deleted file mode 100644 index 5a10fffd..00000000 --- a/build-cs/composer.lock +++ /dev/null @@ -1,322 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "4485bbedba7bcc71ace5f69dbb9b6c47", - "packages": [], - "packages-dev": [ - { - "name": "consistence-community/coding-standard", - "version": "3.11.1", - "source": { - "type": "git", - "url": "https://github.com/consistence-community/coding-standard.git", - "reference": "4632fead8c9ee8f50044fcbce9f66c797b34c0df" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/consistence-community/coding-standard/zipball/4632fead8c9ee8f50044fcbce9f66c797b34c0df", - "reference": "4632fead8c9ee8f50044fcbce9f66c797b34c0df", - "shasum": "" - }, - "require": { - "php": ">=7.4", - "slevomat/coding-standard": "~7.0", - "squizlabs/php_codesniffer": "~3.6.0" - }, - "replace": { - "consistence/coding-standard": "3.10.*" - }, - "require-dev": { - "phing/phing": "2.16.4", - "php-parallel-lint/php-parallel-lint": "1.3.0", - "phpunit/phpunit": "9.5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Consistence\\": [ - "Consistence" - ] - }, - "classmap": [ - "Consistence" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "VaĊĦek Purchart", - "email": "me@vasekpurchart.cz", - "homepage": "http://vasekpurchart.cz/" - } - ], - "description": "Consistence - Coding Standard - PHP Code Sniffer rules", - "keywords": [ - "Coding Standard", - "PHPCodeSniffer", - "codesniffer", - "coding", - "cs", - "phpcs", - "ruleset", - "sniffer", - "standard" - ], - "support": { - "issues": "https://github.com/consistence-community/coding-standard/issues", - "source": "https://github.com/consistence-community/coding-standard/tree/3.11.1" - }, - "time": "2021-05-03T18:13:22+00:00" - }, - { - "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v0.7.2", - "source": { - "type": "git", - "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", - "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", - "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.3", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" - }, - "require-dev": { - "composer/composer": "*", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0" - }, - "type": "composer-plugin", - "extra": { - "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" - }, - "autoload": { - "psr-4": { - "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl/", - "role": "Developer / IT Manager" - }, - { - "name": "Contributors", - "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com/", - "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" - ], - "support": { - "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", - "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" - }, - "time": "2022-02-04T12:51:07+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "1.4.2", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d", - "reference": "4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.4.2" - }, - "time": "2022-03-30T13:33:37+00:00" - }, - { - "name": "slevomat/coding-standard", - "version": "7.1", - "source": { - "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "b521bd358b5f7a7d69e9637fd139e036d8adeb6f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/b521bd358b5f7a7d69e9637fd139e036d8adeb6f", - "reference": "b521bd358b5f7a7d69e9637fd139e036d8adeb6f", - "shasum": "" - }, - "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.4.1", - "squizlabs/php_codesniffer": "^3.6.2" - }, - "require-dev": { - "phing/phing": "2.17.2", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.4.10|1.5.2", - "phpstan/phpstan-deprecation-rules": "1.0.0", - "phpstan/phpstan-phpunit": "1.0.0|1.1.0", - "phpstan/phpstan-strict-rules": "1.1.0", - "phpunit/phpunit": "7.5.20|8.5.21|9.5.19" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "7.x-dev" - } - }, - "autoload": { - "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", - "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/7.1" - }, - "funding": [ - { - "url": "https://github.com/kukulich", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", - "type": "tidelift" - } - ], - "time": "2022-03-29T12:44:16+00:00" - }, - { - "name": "squizlabs/php_codesniffer", - "version": "3.6.2", - "source": { - "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/5e4e71592f69da17871dba6e80dd51bce74a351a", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a", - "shasum": "" - }, - "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" - }, - "bin": [ - "bin/phpcs", - "bin/phpcbf" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Greg Sherwood", - "role": "lead" - } - ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", - "keywords": [ - "phpcs", - "standards" - ], - "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" - }, - "time": "2021-12-12T21:44:58+00:00" - } - ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" -} diff --git a/composer.json b/composer.json index 2f67ed5a..ea9f5cdf 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ ], "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.5.0" + "phpstan/phpstan": "^1.10" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -15,7 +15,7 @@ "require-dev": { "nikic/php-parser": "^4.13.0", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-strict-rules": "^1.0", + "phpstan/phpstan-strict-rules": "^1.5.1", "phpunit/phpunit": "^9.5" }, "config": { diff --git a/extension.neon b/extension.neon index 93d3a9ed..cea2b155 100644 --- a/extension.neon +++ b/extension.neon @@ -51,6 +51,15 @@ services: class: PHPStan\Type\PHPUnit\MockObjectDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Rules\PHPUnit\CoversHelper + - + class: PHPStan\Rules\PHPUnit\AnnotationHelper + - + class: PHPStan\Rules\PHPUnit\DataProviderHelper + factory: @PHPStan\Rules\PHPUnit\DataProviderHelperFactory::create() + - + class: PHPStan\Rules\PHPUnit\DataProviderHelperFactory conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: diff --git a/phpcs.xml b/phpcs.xml deleted file mode 100644 index 95032a6e..00000000 --- a/phpcs.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - src - tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - 10 - - - - - - 10 - - - - - - - - 10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - tests/*/data - diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..53fb96b8 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,16 @@ +parameters: + ignoreErrors: + - + message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + count: 1 + path: tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php + + - + message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeStaticMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + count: 1 + path: tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php + + - + message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + count: 1 + path: tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php diff --git a/phpstan.neon b/phpstan.neon index d71fc5ab..2b8fa1ae 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,24 +3,8 @@ includes: - rules.neon - vendor/phpstan/phpstan-strict-rules/rules.neon - phar://phpstan.phar/conf/bleedingEdge.neon + - phpstan-baseline.neon parameters: excludePaths: - tests/*/data/* - -services: - scopeIsInClass: - class: PHPStan\Internal\ScopeIsInClassTypeSpecifyingExtension - arguments: - isInMethodName: isInClass - removeNullMethodName: getClassReflection - tags: - - phpstan.typeSpecifier.methodTypeSpecifyingExtension - - scopeIsInTrait: - class: PHPStan\Internal\ScopeIsInClassTypeSpecifyingExtension - arguments: - isInMethodName: isInTrait - removeNullMethodName: getTraitReflection - tags: - - phpstan.typeSpecifier.methodTypeSpecifyingExtension diff --git a/rules.neon b/rules.neon index 5be69279..8dc7056b 100644 --- a/rules.neon +++ b/rules.neon @@ -4,3 +4,26 @@ rules: - PHPStan\Rules\PHPUnit\AssertSameWithCountRule - PHPStan\Rules\PHPUnit\MockMethodCallRule - PHPStan\Rules\PHPUnit\ShouldCallParentMethodsRule + +services: + - class: PHPStan\Rules\PHPUnit\ClassCoversExistsRule + - class: PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule + - + class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule + arguments: + checkFunctionNameCase: %checkFunctionNameCase% + deprecationRulesInstalled: %deprecationRulesInstalled% + - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule + - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule + +conditionalTags: + PHPStan\Rules\PHPUnit\ClassCoversExistsRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rules\PHPUnit\DataProviderDeclarationRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% diff --git a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php index 4d463799..2d70b380 100644 --- a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php +++ b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php @@ -11,8 +11,8 @@ use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeWithClassName; use function array_key_exists; +use function count; class MockObjectTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension { @@ -44,11 +44,12 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope); foreach ($types as $type) { - if (!$type instanceof TypeWithClassName) { + $classNames = $type->getObjectClassNames(); + if (count($classNames) !== 1) { continue; } - if (array_key_exists($type->getClassName(), $mockClassNames)) { + if (array_key_exists($classNames[0], $mockClassNames)) { $resultType = TypeCombinator::intersect(...$types); if ($resultType instanceof NeverType) { continue; diff --git a/src/Rules/PHPUnit/AnnotationHelper.php b/src/Rules/PHPUnit/AnnotationHelper.php new file mode 100644 index 00000000..f5529a8f --- /dev/null +++ b/src/Rules/PHPUnit/AnnotationHelper.php @@ -0,0 +1,66 @@ +getText()); + if ($docCommentLines === false) { + return []; + } + + foreach ($docCommentLines as $docCommentLine) { + // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid + $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches); + if ($annotation === false) { + continue; // Line without annotation + } + + if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) { + continue; + } + + if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') { + continue; + } + + $errors[] = RuleErrorBuilder::message( + 'Annotation "' . $matches['annotation'] . '" is invalid, "@' . $matches['property'] . '" should be followed by a space and a value.' + )->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/AssertRuleHelper.php b/src/Rules/PHPUnit/AssertRuleHelper.php index 96735379..3ad79c00 100644 --- a/src/Rules/PHPUnit/AssertRuleHelper.php +++ b/src/Rules/PHPUnit/AssertRuleHelper.php @@ -11,9 +11,11 @@ class AssertRuleHelper { + /** + * @phpstan-assert-if-true Node\Expr\MethodCall|Node\Expr\StaticCall $node + */ public static function isMethodOrStaticCallOnAssert(Node $node, Scope $scope): bool { - $testCaseType = new ObjectType('PHPUnit\Framework\Assert'); if ($node instanceof Node\Expr\MethodCall) { $calledOnType = $scope->getType($node->var); } elseif ($node instanceof Node\Expr\StaticCall) { @@ -42,11 +44,9 @@ public static function isMethodOrStaticCallOnAssert(Node $node, Scope $scope): b return false; } - if (!$testCaseType->isSuperTypeOf($calledOnType)->yes()) { - return false; - } + $testCaseType = new ObjectType('PHPUnit\Framework\Assert'); - return true; + return $testCaseType->isSuperTypeOf($calledOnType)->yes(); } } diff --git a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php index d24d4904..b2860676 100644 --- a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php +++ b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php @@ -4,13 +4,11 @@ use PhpParser\Node; use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\StaticCall; use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; use function count; -use function strtolower; /** * @implements Rule @@ -29,13 +27,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var MethodCall|StaticCall $node */ - $node = $node; - if (count($node->getArgs()) < 2) { return []; } - if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') { + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { return []; } @@ -46,13 +41,13 @@ public function processNode(Node $node, Scope $scope): array if ($expectedArgumentValue->name->toLowerString() === 'true') { return [ - 'You should use assertTrue() instead of assertSame() when expecting "true"', + RuleErrorBuilder::message('You should use assertTrue() instead of assertSame() when expecting "true"')->build(), ]; } if ($expectedArgumentValue->name->toLowerString() === 'false') { return [ - 'You should use assertFalse() instead of assertSame() when expecting "false"', + RuleErrorBuilder::message('You should use assertFalse() instead of assertSame() when expecting "false"')->build(), ]; } diff --git a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php index 672f3496..cf6fc76e 100644 --- a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php +++ b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php @@ -4,13 +4,11 @@ use PhpParser\Node; use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\StaticCall; use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; use function count; -use function strtolower; /** * @implements Rule @@ -29,13 +27,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var MethodCall|StaticCall $node */ - $node = $node; - if (count($node->getArgs()) < 2) { return []; } - if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') { + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { return []; } @@ -46,7 +41,7 @@ public function processNode(Node $node, Scope $scope): array if ($expectedArgumentValue->name->toLowerString() === 'null') { return [ - 'You should use assertNull() instead of assertSame(null, $actual).', + RuleErrorBuilder::message('You should use assertNull() instead of assertSame(null, $actual).')->build(), ]; } diff --git a/src/Rules/PHPUnit/AssertSameWithCountRule.php b/src/Rules/PHPUnit/AssertSameWithCountRule.php index 1f1a3ab2..209614ae 100644 --- a/src/Rules/PHPUnit/AssertSameWithCountRule.php +++ b/src/Rules/PHPUnit/AssertSameWithCountRule.php @@ -4,14 +4,12 @@ use Countable; use PhpParser\Node; -use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\StaticCall; use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\ObjectType; use function count; -use function strtolower; /** * @implements Rule @@ -30,13 +28,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var MethodCall|StaticCall $node */ - $node = $node; - if (count($node->getArgs()) < 2) { return []; } - if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') { + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') { return []; } @@ -45,24 +40,24 @@ public function processNode(Node $node, Scope $scope): array if ( $right instanceof Node\Expr\FuncCall && $right->name instanceof Node\Name - && strtolower($right->name->toString()) === 'count' + && $right->name->toLowerString() === 'count' ) { return [ - 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).', + RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).')->build(), ]; } if ( $right instanceof Node\Expr\MethodCall && $right->name instanceof Node\Identifier - && strtolower($right->name->toString()) === 'count' + && $right->name->toLowerString() === 'count' && count($right->getArgs()) === 0 ) { $type = $scope->getType($right->var); if ((new ObjectType(Countable::class))->isSuperTypeOf($type)->yes()) { return [ - 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).', + RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).')->build(), ]; } } diff --git a/src/Rules/PHPUnit/ClassCoversExistsRule.php b/src/Rules/PHPUnit/ClassCoversExistsRule.php new file mode 100644 index 00000000..a5a0cb70 --- /dev/null +++ b/src/Rules/PHPUnit/ClassCoversExistsRule.php @@ -0,0 +1,93 @@ + + */ +class ClassCoversExistsRule implements Rule +{ + + /** + * Covers helper. + * + * @var CoversHelper + */ + private $coversHelper; + + /** + * Reflection provider. + * + * @var ReflectionProvider + */ + private $reflectionProvider; + + public function __construct( + CoversHelper $coversHelper, + ReflectionProvider $reflectionProvider + ) + { + $this->reflectionProvider = $reflectionProvider; + $this->coversHelper = $coversHelper; + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (!$classReflection->isSubclassOf(TestCase::class)) { + return []; + } + + $errors = []; + $classPhpDoc = $classReflection->getResolvedPhpDoc(); + [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc); + + if (count($classCoversDefaultClasses) >= 2) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@coversDefaultClass is defined multiple times.' + ))->build(); + + return $errors; + } + + $coversDefaultClass = array_shift($classCoversDefaultClasses); + + if ($coversDefaultClass !== null) { + $className = (string) $coversDefaultClass->value; + if (!$this->reflectionProvider->hasClass($className)) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@coversDefaultClass references an invalid class %s.', + $className + ))->build(); + } + } + + foreach ($classCovers as $covers) { + $errors = array_merge( + $errors, + $this->coversHelper->processCovers($node, $covers, null) + ); + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php new file mode 100644 index 00000000..69693dee --- /dev/null +++ b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php @@ -0,0 +1,117 @@ + + */ +class ClassMethodCoversExistsRule implements Rule +{ + + /** + * Covers helper. + * + * @var CoversHelper + */ + private $coversHelper; + + /** + * The file type mapper. + * + * @var FileTypeMapper + */ + private $fileTypeMapper; + + public function __construct( + CoversHelper $coversHelper, + FileTypeMapper $fileTypeMapper + ) + { + $this->coversHelper = $coversHelper; + $this->fileTypeMapper = $fileTypeMapper; + } + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + + if ($classReflection === null) { + return []; + } + + if (!$classReflection->isSubclassOf(TestCase::class)) { + return []; + } + + $classPhpDoc = $classReflection->getResolvedPhpDoc(); + [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc); + + $classCoversStrings = array_map(static function (PhpDocTagNode $covers): string { + return (string) $covers->value; + }, $classCovers); + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $coversDefaultClass = count($classCoversDefaultClasses) === 1 + ? array_shift($classCoversDefaultClasses) + : null; + + $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $node->name->toString(), + $docComment->getText() + ); + + [$methodCovers, $methodCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($methodPhpDoc); + + $errors = []; + + if (count($methodCoversDefaultClasses) > 0) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@coversDefaultClass defined on class method %s.', + $node->name + ))->build(); + } + + foreach ($methodCovers as $covers) { + if (in_array((string) $covers->value, $classCoversStrings, true)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Class already @covers %s so the method @covers is redundant.', + $covers->value + ))->build(); + } + + $errors = array_merge( + $errors, + $this->coversHelper->processCovers($node, $covers, $coversDefaultClass) + ); + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/CoversHelper.php b/src/Rules/PHPUnit/CoversHelper.php new file mode 100644 index 00000000..f17a2063 --- /dev/null +++ b/src/Rules/PHPUnit/CoversHelper.php @@ -0,0 +1,131 @@ +reflectionProvider = $reflectionProvider; + } + + /** + * Gathers @covers and @coversDefaultClass annotations from phpdocs. + * + * @return array{PhpDocTagNode[], PhpDocTagNode[]} + */ + public function getCoverAnnotations(?ResolvedPhpDocBlock $phpDoc): array + { + if ($phpDoc === null) { + return [[], []]; + } + + $phpDocNodes = $phpDoc->getPhpDocNodes(); + + $covers = []; + $coversDefaultClasses = []; + + foreach ($phpDocNodes as $docNode) { + $covers = array_merge( + $covers, + $docNode->getTagsByName('@covers') + ); + + $coversDefaultClasses = array_merge( + $coversDefaultClasses, + $docNode->getTagsByName('@coversDefaultClass') + ); + } + + return [$covers, $coversDefaultClasses]; + } + + /** + * @return RuleError[] errors + */ + public function processCovers( + Node $node, + PhpDocTagNode $phpDocTag, + ?PhpDocTagNode $coversDefaultClass + ): array + { + $errors = []; + $covers = (string) $phpDocTag->value; + + if ($covers === '') { + $errors[] = RuleErrorBuilder::message('@covers value does not specify anything.')->build(); + + return $errors; + } + + $isMethod = strpos($covers, '::') !== false; + $fullName = $covers; + + if ($isMethod) { + [$className, $method] = explode('::', $covers); + } else { + $className = $covers; + } + + if ($className === '' && $node instanceof Node\Stmt\ClassMethod && $coversDefaultClass !== null) { + $className = (string) $coversDefaultClass->value; + $fullName = $className . $covers; + } + + if ($this->reflectionProvider->hasClass($className)) { + $class = $this->reflectionProvider->getClass($className); + + if ($class->isInterface()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@covers value %s references an interface.', + $fullName + ))->build(); + } + + if (isset($method) && $method !== '' && !$class->hasMethod($method)) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@covers value %s references an invalid method.', + $fullName + ))->build(); + } + } elseif (isset($method) && $this->reflectionProvider->hasFunction(new Name($method, []), null)) { + return $errors; + } elseif (!isset($method) && $this->reflectionProvider->hasFunction(new Name($className, []), null)) { + return $errors; + } else { + $error = RuleErrorBuilder::message(sprintf( + '@covers value %s references an invalid %s.', + $fullName, + $isMethod ? 'method' : 'class or function' + )); + + if (strpos($className, '\\') === false) { + $error->tip('The @covers annotation requires a fully qualified name.'); + } + + $errors[] = $error->build(); + } + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/DataProviderDeclarationRule.php b/src/Rules/PHPUnit/DataProviderDeclarationRule.php new file mode 100644 index 00000000..37c586de --- /dev/null +++ b/src/Rules/PHPUnit/DataProviderDeclarationRule.php @@ -0,0 +1,81 @@ + + */ +class DataProviderDeclarationRule implements Rule +{ + + /** + * Data provider helper. + * + * @var DataProviderHelper + */ + private $dataProviderHelper; + + /** + * When set to true, it reports data provider method with incorrect name case. + * + * @var bool + */ + private $checkFunctionNameCase; + + /** + * When phpstan-deprecation-rules is installed, it reports deprecated usages. + * + * @var bool + */ + private $deprecationRulesInstalled; + + public function __construct( + DataProviderHelper $dataProviderHelper, + bool $checkFunctionNameCase, + bool $deprecationRulesInstalled + ) + { + $this->dataProviderHelper = $dataProviderHelper; + $this->checkFunctionNameCase = $checkFunctionNameCase; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + } + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + + if ($classReflection === null || !$classReflection->isSubclassOf(TestCase::class)) { + return []; + } + + $errors = []; + + foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $node, $classReflection) as $dataProviderValue => [$dataProviderClassReflection, $dataProviderMethodName, $lineNumber]) { + $errors = array_merge( + $errors, + $this->dataProviderHelper->processDataProvider( + $dataProviderValue, + $dataProviderClassReflection, + $dataProviderMethodName, + $lineNumber, + $this->checkFunctionNameCase, + $this->deprecationRulesInstalled + ) + ); + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php new file mode 100644 index 00000000..6651b051 --- /dev/null +++ b/src/Rules/PHPUnit/DataProviderHelper.php @@ -0,0 +1,275 @@ +reflectionProvider = $reflectionProvider; + $this->fileTypeMapper = $fileTypeMapper; + $this->phpunit10OrNewer = $phpunit10OrNewer; + } + + /** + * @return iterable + */ + public function getDataProviderMethods( + Scope $scope, + ClassMethod $node, + ClassReflection $classReflection + ): iterable + { + $docComment = $node->getDocComment(); + if ($docComment !== null) { + $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $node->name->toString(), + $docComment->getText() + ); + foreach ($this->getDataProviderAnnotations($methodPhpDoc) as $annotation) { + $dataProviderValue = $this->getDataProviderAnnotationValue($annotation); + if ($dataProviderValue === null) { + // Missing value is already handled in NoMissingSpaceInMethodAnnotationRule + continue; + } + + $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue); + $dataProviderMethod[] = $node->getLine(); + + yield $dataProviderValue => $dataProviderMethod; + } + } + + if (!$this->phpunit10OrNewer) { + return; + } + + foreach ($node->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $dataProviderMethod = null; + if ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataprovider') { + $dataProviderMethod = $this->parseDataProviderAttribute($attr, $classReflection); + } elseif ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataproviderexternal') { + $dataProviderMethod = $this->parseDataProviderExternalAttribute($attr); + } + if ($dataProviderMethod === null) { + continue; + } + + yield from $dataProviderMethod; + } + } + } + + /** + * @return array + */ + private function getDataProviderAnnotations(?ResolvedPhpDocBlock $phpDoc): array + { + if ($phpDoc === null) { + return []; + } + + $phpDocNodes = $phpDoc->getPhpDocNodes(); + + $annotations = []; + + foreach ($phpDocNodes as $docNode) { + $annotations = array_merge( + $annotations, + $docNode->getTagsByName('@dataProvider') + ); + } + + return $annotations; + } + + /** + * @return RuleError[] errors + */ + public function processDataProvider( + string $dataProviderValue, + ?ClassReflection $classReflection, + string $methodName, + int $lineNumber, + bool $checkFunctionNameCase, + bool $deprecationRulesInstalled + ): array + { + if ($classReflection === null) { + $error = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related class not found.', + $dataProviderValue + ))->line($lineNumber)->build(); + + return [$error]; + } + + try { + $dataProviderMethodReflection = $classReflection->getNativeMethod($methodName); + } catch (MissingMethodFromReflectionException $missingMethodFromReflectionException) { + $error = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method not found.', + $dataProviderValue + ))->line($lineNumber)->build(); + + return [$error]; + } + + $errors = []; + + if ($checkFunctionNameCase && $methodName !== $dataProviderMethodReflection->getName()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method is used with incorrect case: %s.', + $dataProviderValue, + $dataProviderMethodReflection->getName() + ))->line($lineNumber)->build(); + } + + if (!$dataProviderMethodReflection->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method must be public.', + $dataProviderValue + ))->line($lineNumber)->build(); + } + + if ($deprecationRulesInstalled && $this->phpunit10OrNewer && !$dataProviderMethodReflection->isStatic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method must be static in PHPUnit 10 and newer.', + $dataProviderValue + ))->line($lineNumber)->build(); + } + + return $errors; + } + + private function getDataProviderAnnotationValue(PhpDocTagNode $phpDocTag): ?string + { + if (preg_match('/^[^ \t]+/', (string) $phpDocTag->value, $matches) !== 1) { + return null; + } + + return $matches[0]; + } + + /** + * @return array{ClassReflection|null, string} + */ + private function parseDataProviderAnnotationValue(Scope $scope, string $dataProviderValue): array + { + $parts = explode('::', $dataProviderValue, 2); + if (count($parts) <= 1) { + return [$scope->getClassReflection(), $dataProviderValue]; + } + + if ($this->reflectionProvider->hasClass($parts[0])) { + return [$this->reflectionProvider->getClass($parts[0]), $parts[1]]; + } + + return [null, $dataProviderValue]; + } + + /** + * @return array|null + */ + private function parseDataProviderExternalAttribute(Attribute $attribute): ?array + { + if (count($attribute->args) !== 2) { + return null; + } + $methodNameArg = $attribute->args[1]->value; + if (!$methodNameArg instanceof String_) { + return null; + } + $classNameArg = $attribute->args[0]->value; + if ($classNameArg instanceof ClassConstFetch && $classNameArg->class instanceof Name) { + $className = $classNameArg->class->toString(); + } elseif ($classNameArg instanceof String_) { + $className = $classNameArg->value; + } else { + return null; + } + + $dataProviderClassReflection = null; + if ($this->reflectionProvider->hasClass($className)) { + $dataProviderClassReflection = $this->reflectionProvider->getClass($className); + $className = $dataProviderClassReflection->getName(); + } + + return [ + sprintf('%s::%s', $className, $methodNameArg->value) => [ + $dataProviderClassReflection, + $methodNameArg->value, + $attribute->getLine(), + ], + ]; + } + + /** + * @return array|null + */ + private function parseDataProviderAttribute(Attribute $attribute, ClassReflection $classReflection): ?array + { + if (count($attribute->args) !== 1) { + return null; + } + $methodNameArg = $attribute->args[0]->value; + if (!$methodNameArg instanceof String_) { + return null; + } + + return [ + $methodNameArg->value => [ + $classReflection, + $methodNameArg->value, + $attribute->getLine(), + ], + ]; + } + +} diff --git a/src/Rules/PHPUnit/DataProviderHelperFactory.php b/src/Rules/PHPUnit/DataProviderHelperFactory.php new file mode 100644 index 00000000..7fc8af0f --- /dev/null +++ b/src/Rules/PHPUnit/DataProviderHelperFactory.php @@ -0,0 +1,57 @@ +reflectionProvider = $reflectionProvider; + $this->fileTypeMapper = $fileTypeMapper; + } + + public function create(): DataProviderHelper + { + $phpUnit10OrNewer = false; + if ($this->reflectionProvider->hasClass(TestCase::class)) { + $testCase = $this->reflectionProvider->getClass(TestCase::class); + $file = $testCase->getFileName(); + if ($file !== null) { + $phpUnitRoot = dirname($file, 3); + $phpUnitComposer = $phpUnitRoot . '/composer.json'; + if (is_file($phpUnitComposer)) { + $composerJson = @file_get_contents($phpUnitComposer); + if ($composerJson !== false) { + $json = json_decode($composerJson, true); + $version = $json['extra']['branch-alias']['dev-main'] ?? null; + if ($version !== null) { + $majorVersion = (int) explode('.', $version)[0]; + if ($majorVersion >= 10) { + $phpUnit10OrNewer = true; + } + } + } + } + } + } + + return new DataProviderHelper($this->reflectionProvider, $this->fileTypeMapper, $phpUnit10OrNewer); + } + +} diff --git a/src/Rules/PHPUnit/MockMethodCallRule.php b/src/Rules/PHPUnit/MockMethodCallRule.php index ae554e1f..90dc0945 100644 --- a/src/Rules/PHPUnit/MockMethodCallRule.php +++ b/src/Rules/PHPUnit/MockMethodCallRule.php @@ -6,10 +6,7 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; -use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\ObjectType; +use PHPStan\Rules\RuleErrorBuilder; use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -32,9 +29,6 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - /** @var Node\Expr\MethodCall $node */ - $node = $node; - if (!$node->name instanceof Node\Identifier || $node->name->name !== 'method') { return []; } @@ -44,53 +38,55 @@ public function processNode(Node $node, Scope $scope): array } $argType = $scope->getType($node->getArgs()[0]->value); - if (!($argType instanceof ConstantStringType)) { + if (count($argType->getConstantStrings()) === 0) { return []; } - $method = $argType->getValue(); - $type = $scope->getType($node->var); + $errors = []; + foreach ($argType->getConstantStrings() as $constantString) { + $method = $constantString->getValue(); + $type = $scope->getType($node->var); - if ( - $type instanceof IntersectionType - && ( - in_array(MockObject::class, $type->getReferencedClasses(), true) - || in_array(Stub::class, $type->getReferencedClasses(), true) - ) - && !$type->hasMethod($method)->yes() - ) { - $mockClass = array_filter($type->getReferencedClasses(), static function (string $class): bool { - return $class !== MockObject::class && $class !== Stub::class; - }); + if ( + ( + in_array(MockObject::class, $type->getObjectClassNames(), true) + || in_array(Stub::class, $type->getObjectClassNames(), true) + ) + && !$type->hasMethod($method)->yes() + ) { + $mockClasses = array_filter($type->getObjectClassNames(), static function (string $class): bool { + return $class !== MockObject::class && $class !== Stub::class; + }); + if (count($mockClasses) === 0) { + continue; + } - return [ - sprintf( + $errors[] = RuleErrorBuilder::message(sprintf( 'Trying to mock an undefined method %s() on class %s.', $method, - implode('&', $mockClass) - ), - ]; - } + implode('&', $mockClasses) + ))->build(); + continue; + } - if ( - $type instanceof GenericObjectType - && $type->getClassName() === InvocationMocker::class - && count($type->getTypes()) > 0 - ) { - $mockClass = $type->getTypes()[0]; + $mockedClassObject = $type->getTemplateType(InvocationMocker::class, 'TMockedClass'); + if ($mockedClassObject->hasMethod($method)->yes()) { + continue; + } - if ($mockClass instanceof ObjectType && !$mockClass->hasMethod($method)->yes()) { - return [ - sprintf( - 'Trying to mock an undefined method %s() on class %s.', - $method, - $mockClass->getClassName() - ), - ]; + $classNames = $mockedClassObject->getObjectClassNames(); + if (count($classNames) === 0) { + continue; } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Trying to mock an undefined method %s() on class %s.', + $method, + implode('|', $classNames) + ))->build(); } - return []; + return $errors; } } diff --git a/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php new file mode 100644 index 00000000..89e3e8ff --- /dev/null +++ b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php @@ -0,0 +1,49 @@ + + */ +class NoMissingSpaceInClassAnnotationRule implements Rule +{ + + /** + * Covers helper. + * + * @var AnnotationHelper + */ + private $annotationHelper; + + public function __construct(AnnotationHelper $annotationHelper) + { + $this->annotationHelper = $annotationHelper; + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->isSubclassOf(TestCase::class) === false) { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + return $this->annotationHelper->processDocComment($docComment); + } + +} diff --git a/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php new file mode 100644 index 00000000..77577206 --- /dev/null +++ b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php @@ -0,0 +1,49 @@ + + */ +class NoMissingSpaceInMethodAnnotationRule implements Rule +{ + + /** + * Covers helper. + * + * @var AnnotationHelper + */ + private $annotationHelper; + + public function __construct(AnnotationHelper $annotationHelper) + { + $this->annotationHelper = $annotationHelper; + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->isSubclassOf(TestCase::class) === false) { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + return $this->annotationHelper->processDocComment($docComment); + } + +} diff --git a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php index 01414061..5a640a1c 100644 --- a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php +++ b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php @@ -97,7 +97,7 @@ private function hasParentClassCall(?array $stmts, string $methodName): bool continue; } - if (strtolower($stmt->expr->name->name) === $methodName) { + if ($stmt->expr->name->toLowerString() === $methodName) { return true; } } diff --git a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php index 36203e65..963b4f57 100644 --- a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php +++ b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php @@ -3,6 +3,8 @@ namespace PHPStan\Type\PHPUnit\Assert; use Closure; +use Countable; +use EmptyIterator; use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; @@ -11,11 +13,13 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PhpParser\Node\Param; +use PhpParser\Node\Scalar\LNumber; +use PhpParser\Node\Stmt; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; -use PHPStan\Type\Constant\ConstantStringType; use ReflectionObject; use function array_key_exists; use function count; @@ -121,15 +125,10 @@ private static function getExpressionResolvers(): array { if (self::$resolvers === null) { self::$resolvers = [ - 'InstanceOf' => static function (Scope $scope, Arg $class, Arg $object): ?Instanceof_ { - $classType = $scope->getType($class->value); - if (!$classType instanceof ConstantStringType) { - return null; - } - + 'InstanceOf' => static function (Scope $scope, Arg $class, Arg $object): Instanceof_ { return new Instanceof_( $object->value, - new Name($classType->getValue()) + $class->value ); }, 'Same' => static function (Scope $scope, Arg $expected, Arg $actual): Identical { @@ -156,6 +155,18 @@ private static function getExpressionResolvers(): array new ConstFetch(new Name('null')) ); }, + 'Empty' => static function (Scope $scope, Arg $actual): Expr\BinaryOp\BooleanOr { + return new Expr\BinaryOp\BooleanOr( + new Instanceof_($actual->value, new Name(EmptyIterator::class)), + new Expr\BinaryOp\BooleanOr( + new Expr\BinaryOp\BooleanAnd( + new Instanceof_($actual->value, new Name(Countable::class)), + new Identical(new FuncCall(new Name('count'), [new Arg($actual->value)]), new LNumber(0)) + ), + new Expr\Empty_($actual->value) + ) + ); + }, 'IsArray' => static function (Scope $scope, Arg $actual): FuncCall { return new FuncCall(new Name('is_array'), [$actual]); }, @@ -190,12 +201,12 @@ private static function getExpressionResolvers(): array return new FuncCall(new Name('is_scalar'), [$actual]); }, 'InternalType' => static function (Scope $scope, Arg $type, Arg $value): ?FuncCall { - $typeType = $scope->getType($type->value); - if (!$typeType instanceof ConstantStringType) { + $typeNames = $scope->getType($type->value)->getConstantStrings(); + if (count($typeNames) !== 1) { return null; } - switch ($typeType->getValue()) { + switch ($typeNames[0]->getValue()) { case 'numeric': $functionName = 'is_numeric'; break; @@ -253,12 +264,58 @@ private static function getExpressionResolvers(): array ] ); }, - 'ArrayHasKey' => static function (Scope $scope, Arg $key, Arg $array): FuncCall { - return new FuncCall(new Name('array_key_exists'), [$key, $array]); + 'ArrayHasKey' => static function (Scope $scope, Arg $key, Arg $array): Expr { + return new Expr\BinaryOp\BooleanOr( + new Expr\BinaryOp\BooleanAnd( + new Expr\Instanceof_($array->value, new Name('ArrayAccess')), + new Expr\MethodCall($array->value, 'offsetExists', [$key]) + ), + new FuncCall(new Name('array_key_exists'), [$key, $array]) + ); }, 'ObjectHasAttribute' => static function (Scope $scope, Arg $property, Arg $object): FuncCall { return new FuncCall(new Name('property_exists'), [$object, $property]); }, + 'ObjectHasProperty' => static function (Scope $scope, Arg $property, Arg $object): FuncCall { + return new FuncCall(new Name('property_exists'), [$object, $property]); + }, + 'Contains' => static function (Scope $scope, Arg $needle, Arg $haystack): Expr { + return new Expr\BinaryOp\BooleanOr( + new Expr\Instanceof_($haystack->value, new Name('Traversable')), + new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('true')))]) + ); + }, + 'ContainsEquals' => static function (Scope $scope, Arg $needle, Arg $haystack): Expr { + return new Expr\BinaryOp\BooleanOr( + new Expr\Instanceof_($haystack->value, new Name('Traversable')), + new Expr\BinaryOp\BooleanAnd( + new Expr\BooleanNot(new Expr\Empty_($haystack->value)), + new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('false')))]) + ) + ); + }, + 'ContainsOnlyInstancesOf' => static function (Scope $scope, Arg $className, Arg $haystack): Expr { + return new Expr\BinaryOp\BooleanOr( + new Expr\Instanceof_($haystack->value, new Name('Traversable')), + new Identical( + $haystack->value, + new FuncCall(new Name('array_filter'), [ + $haystack, + new Arg(new Expr\Closure([ + 'static' => true, + 'params' => [ + new Param(new Expr\Variable('_')), + ], + 'stmts' => [ + new Stmt\Return_( + new FuncCall(new Name('is_a'), [new Arg(new Expr\Variable('_')), $className]) + ), + ], + ])), + ]) + ) + ); + }, ]; } diff --git a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php index cb0b3a17..4f74fe68 100644 --- a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php +++ b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php @@ -7,10 +7,8 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\IntersectionType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -use PHPStan\Type\TypeWithClassName; use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\MockObject; use function array_filter; @@ -33,19 +31,15 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type { $type = $scope->getType($methodCall->var); - if (!($type instanceof IntersectionType)) { - return new ObjectType(InvocationMocker::class); - } - - $mockClasses = array_values(array_filter($type->getTypes(), static function (Type $type): bool { - return !$type instanceof TypeWithClassName || $type->getClassName() !== MockObject::class; + $mockClasses = array_values(array_filter($type->getObjectClassNames(), static function (string $class): bool { + return $class !== MockObject::class; })); if (count($mockClasses) !== 1) { return new ObjectType(InvocationMocker::class); } - return new GenericObjectType(InvocationMocker::class, $mockClasses); + return new GenericObjectType(InvocationMocker::class, [new ObjectType($mockClasses[0])]); } } diff --git a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php index 87e5a3af..08986404 100644 --- a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php @@ -43,6 +43,7 @@ public function testRule(): void [ 'Call to method PHPUnit\Framework\Assert::assertSame() with array and array will always evaluate to false.', 39, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], [ 'Call to method PHPUnit\Framework\Assert::assertSame() with 1 and 1 will always evaluate to true.', diff --git a/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php new file mode 100644 index 00000000..69a7de2f --- /dev/null +++ b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php @@ -0,0 +1,65 @@ + + */ +class ClassCoversExistsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflection = $this->createReflectionProvider(); + + return new ClassCoversExistsRule( + new CoversHelper($reflection), + $reflection + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/class-coverage.php'], [ + [ + '@coversDefaultClass references an invalid class \Not\A\Class.', + 8, + ], + [ + '@coversDefaultClass is defined multiple times.', + 23, + ], + [ + '@covers value \Not\A\Class references an invalid class or function.', + 31, + ], + [ + '@covers value does not specify anything.', + 43, + ], + [ + '@covers value NotFullyQualified references an invalid class or function.', + 50, + 'The @covers annotation requires a fully qualified name.', + ], + [ + '@covers value \DateTimeInterface references an interface.', + 64, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php new file mode 100644 index 00000000..b886b460 --- /dev/null +++ b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php @@ -0,0 +1,65 @@ + + */ +class ClassMethodCoversExistsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflection = $this->createReflectionProvider(); + + return new ClassMethodCoversExistsRule( + new CoversHelper($reflection), + self::getContainer()->getByType(FileTypeMapper::class) + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/method-coverage.php'], [ + [ + '@covers value \Not\A\Class::ignoreThis references an invalid method.', + 14, + ], + [ + '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.', + 28, + ], + [ + '@covers value \Not\A\Class::foo references an invalid method.', + 35, + ], + [ + '@coversDefaultClass defined on class method testBadCoversDefault.', + 50, + ], + [ + '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.', + 62, + ], + [ + 'Class already @covers \PHPUnit\Framework\TestCase so the method @covers is redundant.', + 85, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php new file mode 100644 index 00000000..18cc12b3 --- /dev/null +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -0,0 +1,77 @@ + + */ +class DataProviderDeclarationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflection = $this->createReflectionProvider(); + + return new DataProviderDeclarationRule( + new DataProviderHelper($reflection, self::getContainer()->getByType(FileTypeMapper::class),true), + true, + true + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/data-provider-declaration.php'], [ + [ + '@dataProvider providebaz related method is used with incorrect case: provideBaz.', + 16, + ], + [ + '@dataProvider provideQux related method must be static in PHPUnit 10 and newer.', + 16, + ], + [ + '@dataProvider provideQuux related method must be public.', + 16, + ], + [ + '@dataProvider provideNonExisting related method not found.', + 70, + ], + [ + '@dataProvider NonExisting::provideNonExisting related class not found.', + 70, + ], + [ + '@dataProvider provideNonExisting related method not found.', + 85, + ], + [ + '@dataProvider provideNonExisting2 related method not found.', + 86, + ], + [ + '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.', + 87, + ], + [ + '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.', + 88, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } +} diff --git a/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php new file mode 100644 index 00000000..4c065466 --- /dev/null +++ b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php @@ -0,0 +1,56 @@ + + */ +class ImpossibleCheckTypeMethodCallRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return self::getContainer()->getByType(ImpossibleCheckTypeMethodCallRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/impossible-assert-method-call.php'], [ + [ + 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{} will always evaluate to true.', + 14, + ], + [ + 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{1, 2, 3} will always evaluate to false.', + 15, + ], + ]); + } + + public function testBug141(): void + { + $this->analyse([__DIR__ . '/data/bug-141.php'], [ + [ + "Call to method PHPUnit\Framework\Assert::assertEmpty() with non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> will always evaluate to false.", + 23, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + __DIR__ . '/../../../vendor/phpstan/phpstan-strict-rules/rules.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php new file mode 100644 index 00000000..e28fde15 --- /dev/null +++ b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php @@ -0,0 +1,87 @@ + + */ +class NoMissingSpaceInClassAnnotationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NoMissingSpaceInClassAnnotationRule(new AnnotationHelper()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/InvalidClassCoversAnnotation.php'], [ + [ + 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.', + 36, + ], + [ + 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.', + 36, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php new file mode 100644 index 00000000..2926ec93 --- /dev/null +++ b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php @@ -0,0 +1,87 @@ + + */ +class NoMissingSpaceInMethodAnnotationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NoMissingSpaceInMethodAnnotationRule(new AnnotationHelper()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/InvalidMethodCoversAnnotation.php'], [ + [ + 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.', + 12, + ], + [ + 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.', + 19, + ], + [ + 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.', + 27, + ], + [ + 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.', + 27, + ], + [ + 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.', + 33, + ], + [ + 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.', + 39, + ], + [ + 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.', + 45, + ], + [ + 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.', + 52, + ], + [ + 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.', + 58, + ], + [ + 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.', + 64, + ], + [ + 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.', + 70, + ], + [ + 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.', + 76, + ], + [ + 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.', + 82, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php new file mode 100644 index 00000000..11f9b904 --- /dev/null +++ b/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php @@ -0,0 +1,38 @@ += 5.3 + * @testDox + * @testDox foo bar + * @testWith + * @testWith ['foo', 'bar'] + * @ticket + * @ticket 1234 + * @uses + * @uses foo + */ +class InvalidClassCoversAnnotation extends \PHPUnit\Framework\TestCase +{ +} diff --git a/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php new file mode 100644 index 00000000..9154937b --- /dev/null +++ b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php @@ -0,0 +1,83 @@ += 5.3 + */ + public function requiresAnnotation() {} + + /** + * @testDox + * @testDox foo bar + */ + public function testDox() {} + + /** + * @testWith + * @testWith ['foo', 'bar'] + */ + public function testWith() {} + + /** + * @ticket + * @ticket 1234 + */ + public function ticket() {} + + /** + * @uses + * @uses foo + */ + public function uses() {} +} diff --git a/tests/Rules/PHPUnit/data/bug-141.php b/tests/Rules/PHPUnit/data/bug-141.php new file mode 100644 index 00000000..30011049 --- /dev/null +++ b/tests/Rules/PHPUnit/data/bug-141.php @@ -0,0 +1,53 @@ + $a + */ + public function doFoo(array $a): void + { + $this->assertEmpty($a); + } + + /** + * @param non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> $a + */ + public function doBar(array $a): void + { + $this->assertEmpty($a); + } + + public function doBaz(): void + { + $expected = [ + '0.6.0' => true, + '1.0.0' => true, + '1.0.x-dev' => true, + '1.1.x-dev' => true, + 'dev-feature-b' => true, + 'dev-feature/a-1.0-B' => true, + 'dev-master' => true, + '9999999-dev' => true, // alias of dev-master + ]; + + /** @var array */ + $packages = ['0.6.0', '1.0.0', '1']; + + foreach ($packages as $version) { + if (isset($expected[$version])) { + unset($expected[$version]); + } else { + throw new \Exception('Unexpected version '.$version); + } + } + + $this->assertEmpty($expected); + } + +} diff --git a/tests/Rules/PHPUnit/data/class-coverage.php b/tests/Rules/PHPUnit/data/class-coverage.php new file mode 100644 index 00000000..c231f772 --- /dev/null +++ b/tests/Rules/PHPUnit/data/class-coverage.php @@ -0,0 +1,66 @@ +assertEmpty($c); + $this->assertEmpty([]); + $this->assertEmpty([1, 2, 3]); + } + + public function doBar(object $o): void + { + $this->assertEmpty($o); + } + + /** + * @param class-string<\Exception> $name + * @return void + */ + public function doBaz(\Exception $e, string $name): void + { + $this->assertInstanceOf($name, $e); + } + +} diff --git a/tests/Rules/PHPUnit/data/method-coverage.php b/tests/Rules/PHPUnit/data/method-coverage.php new file mode 100644 index 00000000..77ed1ab8 --- /dev/null +++ b/tests/Rules/PHPUnit/data/method-coverage.php @@ -0,0 +1,87 @@ +createStub(Bar::class)->method('doBadThing'); } + public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock) + { + $mock->method('doFoo'); + } + } class Bar { @@ -51,3 +56,18 @@ public function method(string $string) return $string; } }; + +final class FinalFoo +{ + +} + +class FinalFooTest extends \PHPUnit\Framework\TestCase +{ + + public function testMockFinalClass() + { + $this->createMock(FinalFoo::class)->method('doFoo'); + } + +} diff --git a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php index f2a254d7..2d43f8bc 100644 --- a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php +++ b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php @@ -11,11 +11,15 @@ class AssertFunctionTypeSpecifyingExtensionTest extends TypeInferenceTestCase /** @return mixed[] */ public function dataFileAsserts(): iterable { - if (!function_exists('PHPUnit\\Framework\\assertInstanceOf')) { - return []; + if (function_exists('PHPUnit\\Framework\\assertInstanceOf')) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function.php'); } - yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function.php'); + if (function_exists('PHPUnit\\Framework\\assertObjectHasProperty')) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function-9.6.11.php'); + } + + return []; } /** diff --git a/tests/Type/PHPUnit/data/assert-function-9.6.11.php b/tests/Type/PHPUnit/data/assert-function-9.6.11.php new file mode 100644 index 00000000..dea6b851 --- /dev/null +++ b/tests/Type/PHPUnit/data/assert-function-9.6.11.php @@ -0,0 +1,17 @@ + $class + */ + public function assertInstanceOfWorksWithTemplate($o, $class): void + { + assertInstanceOf($class, $o); + assertType(\DateTimeInterface::class, $o); + } + + public function arrayHasNumericKey(array $a, \ArrayAccess $b): void { assertArrayHasKey(0, $a); assertType('array&hasOffset(0)', $a); + + assertArrayHasKey(0, $b); + assertType('ArrayAccess', $b); } - public function arrayHasStringKey(array $a): void + public function arrayHasStringKey(array $a, \ArrayAccess $b): void { assertArrayHasKey('key', $a); assertType("array&hasOffset('key')", $a); + + assertArrayHasKey('key', $b); + assertType("ArrayAccess", $b); } public function objectHasAttribute(object $a): void @@ -36,4 +57,37 @@ public function objectHasAttribute(object $a): void assertType("object&hasProperty(property)", $a); } + public function testEmpty($a): void + { + assertEmpty($a); + assertType("0|0.0|''|'0'|array{}|Countable|EmptyIterator|false|null", $a); + } + + public function contains(array $a, \Traversable $b): void + { + assertContains('foo', $a); + assertType('non-empty-array', $a); + + assertContains('foo', $b); + assertType('Traversable', $b); + } + + public function containsEquals(array $a, \Traversable $b): void + { + assertContainsEquals('foo', $a); + assertType('non-empty-array', $a); + + assertContainsEquals('foo', $b); + assertType('Traversable', $b); + } + + public function containsOnlyInstancesOf(array $a, \Traversable $b): void + { + assertContainsOnlyInstancesOf(\stdClass::class, $a); + assertType('array', $a); + + assertContainsOnlyInstancesOf(\stdClass::class, $b); + assertType('Traversable', $b); + } + }