diff --git a/.gitattributes b/.gitattributes index 615bf05..00d6b17 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,12 +2,12 @@ *.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 +/build-cs export-ignore +/tests export-ignore +/tmp export-ignore +/Makefile export-ignore +/phpcs.xml export-ignore +/phpstan.neon export-ignore +/phpstan-baseline.neon export-ignore +/phpunit.xml export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 15b7733..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 -updates: - - package-ecosystem: composer - directory: "/" - schedule: - interval: monthly - open-pull-requests-limit: 10 - - package-ecosystem: composer - directory: "/build-cs" - schedule: - interval: monthly - open-pull-requests-limit: 10 - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: monthly - open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..b775cc1 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,24 @@ +{ + "extends": [ + "config:base", + "schedule:weekly" + ], + "rangeStrategy": "update-lockfile", + "packageRules": [ + { + "matchPaths": ["+(composer.json)"], + "enabled": true, + "groupName": "root-composer" + }, + { + "matchPaths": ["build-cs/**"], + "enabled": true, + "groupName": "build-cs" + }, + { + "matchPaths": [".github/**"], + "enabled": true, + "groupName": "github-actions" + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59b79ed..3525498 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "master" + - "1.1.x" jobs: lint: @@ -16,16 +16,16 @@ jobs: strategy: matrix: php-version: - - "7.1" - "7.2" - "7.3" - "7.4" - "8.0" - "8.1" + - "8.2" steps: - name: "Checkout" - uses: "actions/checkout@v2" + uses: actions/checkout@v3 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -37,10 +37,10 @@ jobs: run: "composer validate" - name: "Install dependencies" - run: "composer install --no-interaction --no-progress --no-suggest" + run: "composer install --no-interaction --no-progress" - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.1' || matrix.php-version == '7.2' || matrix.php-version == '7.3' + if: matrix.php-version == '7.2' || matrix.php-version == '7.3' run: "composer require --dev phpunit/phpunit:^7.5.20 --update-with-dependencies" - name: "Lint" @@ -53,7 +53,7 @@ jobs: steps: - name: "Checkout" - uses: "actions/checkout@v2" + uses: actions/checkout@v3 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -65,7 +65,7 @@ jobs: run: "composer validate" - name: "Install dependencies" - run: "composer install --no-interaction --no-progress --no-suggest" + run: "composer install --no-interaction --no-progress" - name: "Lint" run: "make lint" @@ -81,19 +81,19 @@ jobs: fail-fast: false matrix: php-version: - - "7.1" - "7.2" - "7.3" - "7.4" - "8.0" - "8.1" + - "8.2" dependencies: - "lowest" - "highest" steps: - name: "Checkout" - uses: "actions/checkout@v2" + uses: actions/checkout@v3 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -103,14 +103,14 @@ jobs: - name: "Install lowest dependencies" if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + run: "composer update --prefer-lowest --no-interaction --no-progress" - name: "Install highest dependencies" if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + run: "composer update --no-interaction --no-progress" - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.1' || matrix.php-version == '7.2' || matrix.php-version == '7.3' + if: matrix.php-version == '7.2' || matrix.php-version == '7.3' run: "composer require --dev phpunit/phpunit:^7.5.20 --update-with-dependencies" - name: "Tests" @@ -124,19 +124,19 @@ jobs: fail-fast: false matrix: php-version: - - "7.1" - "7.2" - "7.3" - "7.4" - "8.0" - "8.1" + - "8.2" dependencies: - "lowest" - "highest" steps: - name: "Checkout" - uses: "actions/checkout@v2" + uses: actions/checkout@v3 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -148,14 +148,14 @@ jobs: - name: "Install lowest dependencies" if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + run: "composer update --prefer-lowest --no-interaction --no-progress" - name: "Install highest dependencies" if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + run: "composer update --no-interaction --no-progress" - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.1' || matrix.php-version == '7.2' || matrix.php-version == '7.3' + if: matrix.php-version == '7.2' || matrix.php-version == '7.3' run: "composer require --dev phpunit/phpunit:^7.5.20 --update-with-dependencies" - name: "PHPStan" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 0000000..8452d98 --- /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 960c1ba..4c7990d 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -8,14 +8,14 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2 + - uses: dessant/lock-threads@v4 with: github-token: ${{ github.token }} - issue-lock-inactive-days: '31' - issue-exclude-created-before: '' - issue-exclude-labels: '' - issue-lock-labels: '' - issue-lock-comment: > + issue-inactive-days: '31' + exclude-issue-created-before: '' + exclude-any-issue-labels: '' + add-issue-labels: '' + issue-comment: > This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml new file mode 100644 index 0000000..6a1c815 --- /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-tweet.yml b/.github/workflows/release-tweet.yml new file mode 100644 index 0000000..09b39de --- /dev/null +++ b/.github/workflows/release-tweet.yml @@ -0,0 +1,24 @@ +name: Tweet release + +# More triggers +# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release +on: + release: + types: [published] + +jobs: + tweet: + runs-on: ubuntu-latest + steps: + - uses: Eomm/why-don-t-you-tweet@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 + tweet-message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan" + env: + # Get your tokens from https://developer.twitter.com/apps + TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }} + TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }} + TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 225470a..bac4a00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,19 +14,19 @@ jobs: steps: - name: "Checkout" - uses: "actions/checkout@v2" + uses: actions/checkout@v3 - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v1.0.0 + uses: metcalfc/changelog-generator@v4.0.1 with: - myToken: ${{ secrets.GITHUB_TOKEN }} + myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: "Create release" id: create-release uses: actions/create-release@v1 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} diff --git a/.gitignore b/.gitignore index d6a83e5..2db2131 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /tests/tmp /vendor -composer.lock +/composer.lock .phpunit.result.cache diff --git a/Makefile b/Makefile index fe917d3..b34d0fe 100644 --- a/Makefile +++ b/Makefile @@ -21,3 +21,7 @@ cs-fix: .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 index ff72e2d..61ead86 100644 --- a/build-cs/.gitignore +++ b/build-cs/.gitignore @@ -1,2 +1 @@ -/composer.lock /vendor diff --git a/build-cs/composer.json b/build-cs/composer.json index ed7744e..e307971 100644 --- a/build-cs/composer.json +++ b/build-cs/composer.json @@ -2,6 +2,11 @@ "require-dev": { "consistence-community/coding-standard": "^3.10", "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "slevomat/coding-standard": "^6.4" + "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 new file mode 100644 index 0000000..4bcc8de --- /dev/null +++ b/build-cs/composer.lock @@ -0,0 +1,322 @@ +{ + "_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.5.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "981cc368a216c988e862a75e526b6076987d1b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/981cc368a216c988e862a75e526b6076987d1b50", + "reference": "981cc368a216c988e862a75e526b6076987d1b50", + "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.5.1" + }, + "time": "2022-05-05T11:32:40+00:00" + }, + { + "name": "slevomat/coding-standard", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7", + "php": "^7.2 || ^8.0", + "phpstan/phpdoc-parser": "^1.5.1", + "squizlabs/php_codesniffer": "^3.6.2" + }, + "require-dev": { + "phing/phing": "2.17.3", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpstan/phpstan": "1.4.10|1.7.1", + "phpstan/phpstan-deprecation-rules": "1.0.0", + "phpstan/phpstan-phpunit": "1.0.0|1.1.1", + "phpstan/phpstan-strict-rules": "1.2.3", + "phpunit/phpunit": "7.5.20|8.5.21|9.5.20" + }, + "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.2.1" + }, + "funding": [ + { + "url": "https://github.com/kukulich", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" + } + ], + "time": "2022-05-25T10:58:12+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 8c3630b..594629f 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,8 @@ "MIT" ], "require": { - "php": "^7.1 || ^8.0", - "phpstan/phpstan": "^1.0" + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.10" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -25,9 +25,6 @@ "sort-packages": true }, "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - }, "phpstan": { "includes": [ "extension.neon", diff --git a/extension.neon b/extension.neon index 1025448..cea2b15 100644 --- a/extension.neon +++ b/extension.neon @@ -12,6 +12,7 @@ parameters: - stubs/InvocationMocker.stub - stubs/MockBuilder.stub - stubs/MockObject.stub + - stubs/Stub.stub - stubs/TestCase.stub exceptions: uncheckedExceptionRegexes: @@ -50,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 index ce6e811..95032a6 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,5 +1,6 @@ - + + @@ -8,58 +9,103 @@ src tests + - - + + - + + + + + - + - - - - - + + + + + 10 - - - + + 10 + - - - + + + + + 10 + + + - - + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - tests/tmp + + tests/*/data diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..53fb96b --- /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 d71fc5a..2b8fa1a 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/phpunit.xml b/phpunit.xml index 8d53d3f..8f71615 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,7 +10,7 @@ beStrictAboutTodoAnnotatedTests="true" failOnRisky="true" failOnWarning="true" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xml" > diff --git a/rules.neon b/rules.neon index 5be6927..8dc7056 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 955b818..2d70b38 100644 --- a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php +++ b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php @@ -2,6 +2,7 @@ namespace PHPStan\PhpDoc\PHPUnit; +use PHPStan\Analyser\NameScope; use PHPStan\PhpDoc\TypeNodeResolver; use PHPStan\PhpDoc\TypeNodeResolverAwareExtension; use PHPStan\PhpDoc\TypeNodeResolverExtension; @@ -9,7 +10,9 @@ use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeWithClassName; +use PHPStan\Type\TypeCombinator; +use function array_key_exists; +use function count; class MockObjectTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension { @@ -27,7 +30,7 @@ public function getCacheKey(): string return 'phpunit-v1'; } - public function resolve(TypeNode $typeNode, \PHPStan\Analyser\NameScope $nameScope): ?Type + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type { if (!$typeNode instanceof UnionTypeNode) { return null; @@ -36,16 +39,18 @@ public function resolve(TypeNode $typeNode, \PHPStan\Analyser\NameScope $nameSco static $mockClassNames = [ 'PHPUnit_Framework_MockObject_MockObject' => true, 'PHPUnit\Framework\MockObject\MockObject' => true, + 'PHPUnit\Framework\MockObject\Stub' => true, ]; $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)) { - $resultType = \PHPStan\Type\TypeCombinator::intersect(...$types); + 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 0000000..f5529a8 --- /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 e8edc50..8c288b2 100644 --- a/src/Rules/PHPUnit/AssertRuleHelper.php +++ b/src/Rules/PHPUnit/AssertRuleHelper.php @@ -5,10 +5,15 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Type\ObjectType; +use function in_array; +use function strtolower; 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'); diff --git a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php index 9712185..6a8eb7d 100644 --- a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php +++ b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php @@ -3,18 +3,21 @@ namespace PHPStan\Rules\PHPUnit; use PhpParser\Node; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; -use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Rules\Rule; +use function count; /** - * @implements \PHPStan\Rules\Rule<\PhpParser\NodeAbstract> + * @implements Rule */ -class AssertSameBooleanExpectedRule implements \PHPStan\Rules\Rule +class AssertSameBooleanExpectedRule implements Rule { public function getNodeType(): string { - return \PhpParser\NodeAbstract::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array @@ -23,30 +26,31 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\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 []; } - $leftType = $scope->getType($node->getArgs()[0]->value); - if (!$leftType instanceof ConstantBooleanType) { + $expectedArgumentValue = $node->getArgs()[0]->value; + if (!($expectedArgumentValue instanceof ConstFetch)) { return []; } - if ($leftType->getValue()) { + if ($expectedArgumentValue->name->toLowerString() === 'true') { return [ 'You should use assertTrue() instead of assertSame() when expecting "true"', ]; } - return [ - 'You should use assertFalse() instead of assertSame() when expecting "false"', - ]; + if ($expectedArgumentValue->name->toLowerString() === 'false') { + return [ + 'You should use assertFalse() instead of assertSame() when expecting "false"', + ]; + } + + return []; } } diff --git a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php index 9337cc8..1030223 100644 --- a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php +++ b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php @@ -3,18 +3,21 @@ namespace PHPStan\Rules\PHPUnit; use PhpParser\Node; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; -use PHPStan\Type\NullType; +use PHPStan\Rules\Rule; +use function count; /** - * @implements \PHPStan\Rules\Rule<\PhpParser\NodeAbstract> + * @implements Rule */ -class AssertSameNullExpectedRule implements \PHPStan\Rules\Rule +class AssertSameNullExpectedRule implements Rule { public function getNodeType(): string { - return \PhpParser\NodeAbstract::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array @@ -23,19 +26,19 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\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 []; } - $leftType = $scope->getType($node->getArgs()[0]->value); + $expectedArgumentValue = $node->getArgs()[0]->value; + if (!($expectedArgumentValue instanceof ConstFetch)) { + return []; + } - if ($leftType instanceof NullType) { + if ($expectedArgumentValue->name->toLowerString() === 'null') { return [ 'You should use assertNull() instead of assertSame(null, $actual).', ]; diff --git a/src/Rules/PHPUnit/AssertSameWithCountRule.php b/src/Rules/PHPUnit/AssertSameWithCountRule.php index 3777b3f..876dd87 100644 --- a/src/Rules/PHPUnit/AssertSameWithCountRule.php +++ b/src/Rules/PHPUnit/AssertSameWithCountRule.php @@ -2,19 +2,23 @@ namespace PHPStan\Rules\PHPUnit; +use Countable; use PhpParser\Node; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; use PHPStan\Type\ObjectType; +use function count; /** - * @implements \PHPStan\Rules\Rule<\PhpParser\NodeAbstract> + * @implements Rule */ -class AssertSameWithCountRule implements \PHPStan\Rules\Rule +class AssertSameWithCountRule implements Rule { public function getNodeType(): string { - return \PhpParser\NodeAbstract::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array @@ -23,13 +27,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @var \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\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 []; } @@ -38,7 +39,7 @@ 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)).', @@ -48,12 +49,12 @@ public function processNode(Node $node, Scope $scope): array 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()) { + if ((new ObjectType(Countable::class))->isSuperTypeOf($type)->yes()) { return [ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).', ]; diff --git a/src/Rules/PHPUnit/ClassCoversExistsRule.php b/src/Rules/PHPUnit/ClassCoversExistsRule.php new file mode 100644 index 0000000..a5a0cb7 --- /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 0000000..95e6cc8 --- /dev/null +++ b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php @@ -0,0 +1,118 @@ + + */ +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 []; + } + + $errors = []; + $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 0000000..66d6edf --- /dev/null +++ b/src/Rules/PHPUnit/CoversHelper.php @@ -0,0 +1,125 @@ +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 (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 0000000..37c586d --- /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 0000000..6651b05 --- /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 0000000..7fc8af0 --- /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 d7551dc..da8a95d 100644 --- a/src/Rules/PHPUnit/MockMethodCallRule.php +++ b/src/Rules/PHPUnit/MockMethodCallRule.php @@ -3,18 +3,22 @@ namespace PHPStan\Rules\PHPUnit; use PhpParser\Node; +use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; -use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\ObjectType; +use PHPStan\Rules\Rule; use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; +use function array_filter; +use function count; +use function implode; +use function in_array; +use function sprintf; /** - * @implements \PHPStan\Rules\Rule<\PhpParser\Node\Expr\MethodCall> + * @implements Rule */ -class MockMethodCallRule implements \PHPStan\Rules\Rule +class MockMethodCallRule implements Rule { public function getNodeType(): string @@ -36,50 +40,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) - && !$type->hasMethod($method)->yes() - ) { - $mockClass = array_filter($type->getReferencedClasses(), function (string $class): bool { - return $class !== MockObject::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[] = sprintf( 'Trying to mock an undefined method %s() on class %s.', $method, - \implode('&', $mockClass) - ), - ]; - } + implode('&', $mockClasses) + ); + continue; + } - if ( - $type instanceof GenericObjectType - && $type->getClassName() === InvocationMocker::class - && count($type->getTypes()) > 0 - ) { - $mockClass = $type->getTypes()[0]; - - if ($mockClass instanceof ObjectType && !$mockClass->hasMethod($method)->yes()) { - return [ - sprintf( - 'Trying to mock an undefined method %s() on class %s.', - $method, - $mockClass->getClassName() - ), - ]; + $mockedClassObject = $type->getTemplateType(InvocationMocker::class, 'TMockedClass'); + if ($mockedClassObject->hasMethod($method)->yes()) { + continue; } + + $classNames = $mockedClassObject->getObjectClassNames(); + if (count($classNames) === 0) { + continue; + } + + $errors[] = sprintf( + 'Trying to mock an undefined method %s() on class %s.', + $method, + implode('|', $classNames) + ); } - return []; + return $errors; } } diff --git a/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php new file mode 100644 index 0000000..89e3e8f --- /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 0000000..7757720 --- /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 de8a778..5a640a1 100644 --- a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php +++ b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php @@ -5,13 +5,17 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassMethodNode; +use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPUnit\Framework\TestCase; +use function in_array; +use function sprintf; +use function strtolower; /** - * @implements \PHPStan\Rules\Rule + * @implements Rule */ -class ShouldCallParentMethodsRule implements \PHPStan\Rules\Rule +class ShouldCallParentMethodsRule implements Rule { public function getNodeType(): string @@ -62,9 +66,7 @@ public function processNode(Node $node, Scope $scope): array /** * @param Node\Stmt[]|null $stmts - * @param string $methodName * - * @return bool */ private function hasParentClassCall(?array $stmts, string $methodName): bool { @@ -95,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/AssertFunctionTypeSpecifyingExtension.php b/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php index e20e13d..31805a3 100644 --- a/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php +++ b/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php @@ -10,6 +10,9 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\FunctionTypeSpecifyingExtension; +use function strlen; +use function strpos; +use function substr; class AssertFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension { diff --git a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php index 2cd354c..2276b51 100644 --- a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php +++ b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php @@ -2,27 +2,37 @@ 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; +use PhpParser\Node\Expr\BooleanNot; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PhpParser\Node\Scalar\LNumber; 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; +use function strlen; +use function strpos; +use function substr; class AssertTypeSpecifyingExtensionHelper { - /** @var \Closure[] */ + /** @var Closure[] */ private static $resolvers; /** - * @param string $name - * @param \PhpParser\Node\Arg[] $args - * @return bool + * @param Arg[] $args */ public static function isSupported( string $name, @@ -37,9 +47,9 @@ public static function isSupported( } $resolver = $resolvers[$trimmedName]; - $resolverReflection = new \ReflectionObject($resolver); + $resolverReflection = new ReflectionObject($resolver); - return count($args) >= (count($resolverReflection->getMethod('__invoke')->getParameters()) - 1); + return count($args) >= count($resolverReflection->getMethod('__invoke')->getParameters()) - 1; } private static function trimName(string $name): string @@ -62,11 +72,7 @@ private static function trimName(string $name): string } /** - * @param TypeSpecifier $typeSpecifier - * @param Scope $scope - * @param string $name - * @param \PhpParser\Node\Arg[] $args $args - * @return SpecifiedTypes + * @param Arg[] $args $args */ public static function specifyTypes( TypeSpecifier $typeSpecifier, @@ -87,16 +93,13 @@ public static function specifyTypes( } /** - * @param Scope $scope - * @param string $name - * @param \PhpParser\Node\Arg[] $args - * @return \PhpParser\Node\Expr|null + * @param Arg[] $args */ private static function createExpression( Scope $scope, string $name, array $args - ): ?\PhpParser\Node\Expr + ): ?Expr { $trimmedName = self::trimName($name); $resolvers = self::getExpressionResolvers(); @@ -107,94 +110,107 @@ private static function createExpression( } if (strpos($name, 'Not') !== false) { - $expression = new \PhpParser\Node\Expr\BooleanNot($expression); + $expression = new BooleanNot($expression); } return $expression; } /** - * @return \Closure[] + * @return Closure[] */ private static function getExpressionResolvers(): array { if (self::$resolvers === null) { self::$resolvers = [ - 'InstanceOf' => function (Scope $scope, Arg $class, Arg $object): ?Instanceof_ { + 'InstanceOf' => static function (Scope $scope, Arg $class, Arg $object): ?Instanceof_ { $classType = $scope->getType($class->value); - if (!$classType instanceof ConstantStringType) { + $classNames = $classType->getConstantStrings(); + if (count($classNames) !== 1) { return null; } - return new \PhpParser\Node\Expr\Instanceof_( + return new Instanceof_( $object->value, - new \PhpParser\Node\Name($classType->getValue()) + new Name($classNames[0]->getValue()) ); }, - 'Same' => function (Scope $scope, Arg $expected, Arg $actual): Identical { - return new \PhpParser\Node\Expr\BinaryOp\Identical( + 'Same' => static function (Scope $scope, Arg $expected, Arg $actual): Identical { + return new Identical( $expected->value, $actual->value ); }, - 'True' => function (Scope $scope, Arg $actual): Identical { - return new \PhpParser\Node\Expr\BinaryOp\Identical( + 'True' => static function (Scope $scope, Arg $actual): Identical { + return new Identical( $actual->value, - new \PhpParser\Node\Expr\ConstFetch(new Name('true')) + new ConstFetch(new Name('true')) ); }, - 'False' => function (Scope $scope, Arg $actual): Identical { - return new \PhpParser\Node\Expr\BinaryOp\Identical( + 'False' => static function (Scope $scope, Arg $actual): Identical { + return new Identical( $actual->value, - new \PhpParser\Node\Expr\ConstFetch(new Name('false')) + new ConstFetch(new Name('false')) ); }, - 'Null' => function (Scope $scope, Arg $actual): Identical { - return new \PhpParser\Node\Expr\BinaryOp\Identical( + 'Null' => static function (Scope $scope, Arg $actual): Identical { + return new Identical( $actual->value, - new \PhpParser\Node\Expr\ConstFetch(new Name('null')) + new ConstFetch(new Name('null')) ); }, - 'IsArray' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_array'), [$actual]); + '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]); }, - 'IsBool' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_bool'), [$actual]); + 'IsBool' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_bool'), [$actual]); }, - 'IsCallable' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_callable'), [$actual]); + 'IsCallable' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_callable'), [$actual]); }, - 'IsFloat' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_float'), [$actual]); + 'IsFloat' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_float'), [$actual]); }, - 'IsInt' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_int'), [$actual]); + 'IsInt' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_int'), [$actual]); }, - 'IsIterable' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_iterable'), [$actual]); + 'IsIterable' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_iterable'), [$actual]); }, - 'IsNumeric' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_numeric'), [$actual]); + 'IsNumeric' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_numeric'), [$actual]); }, - 'IsObject' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_object'), [$actual]); + 'IsObject' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_object'), [$actual]); }, - 'IsResource' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_resource'), [$actual]); + 'IsResource' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_resource'), [$actual]); }, - 'IsString' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_string'), [$actual]); + 'IsString' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_string'), [$actual]); }, - 'IsScalar' => function (Scope $scope, Arg $actual): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('is_scalar'), [$actual]); + 'IsScalar' => static function (Scope $scope, Arg $actual): FuncCall { + return new FuncCall(new Name('is_scalar'), [$actual]); }, - 'InternalType' => function (Scope $scope, Arg $type, Arg $value): ?FuncCall { - $typeType = $scope->getType($type->value); - if (!$typeType instanceof ConstantStringType) { + 'InternalType' => static function (Scope $scope, Arg $type, Arg $value): ?FuncCall { + $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; @@ -245,18 +261,18 @@ private static function getExpressionResolvers(): array return null; } - return new \PhpParser\Node\Expr\FuncCall( + return new FuncCall( new Name($functionName), [ $value, ] ); }, - 'ArrayHasKey' => function (Scope $scope, Arg $key, Arg $array): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('array_key_exists'), [$key, $array]); + 'ArrayHasKey' => static function (Scope $scope, Arg $key, Arg $array): FuncCall { + return new FuncCall(new Name('array_key_exists'), [$key, $array]); }, - 'ObjectHasAttribute' => function (Scope $scope, Arg $property, Arg $object): FuncCall { - return new \PhpParser\Node\Expr\FuncCall(new Name('property_exists'), [$object, $property]); + 'ObjectHasAttribute' => static function (Scope $scope, Arg $property, Arg $object): FuncCall { + return new FuncCall(new Name('property_exists'), [$object, $property]); }, ]; } diff --git a/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php b/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php index 51e2222..44764f6 100644 --- a/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php +++ b/src/Type/PHPUnit/InvocationMockerDynamicReturnTypeExtension.php @@ -5,10 +5,11 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Type; use PHPUnit\Framework\MockObject\Builder\InvocationMocker; -class InvocationMockerDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension +class InvocationMockerDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { public function getClass(): string diff --git a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php index d6ae40d..5390b11 100644 --- a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php +++ b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php @@ -5,10 +5,12 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Type; use PHPUnit\Framework\MockObject\MockBuilder; +use function in_array; -class MockBuilderDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension +class MockBuilderDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { public function getClass(): string diff --git a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php index 06e69b3..4f74fe6 100644 --- a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php +++ b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php @@ -5,15 +5,17 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; 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; +use function array_values; +use function count; -class MockObjectDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension +class MockObjectDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { public function getClass(): string @@ -29,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(), 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/stubs/Stub.stub b/stubs/Stub.stub new file mode 100644 index 0000000..62771cc --- /dev/null +++ b/stubs/Stub.stub @@ -0,0 +1,8 @@ + $originalClassName - * @phpstan-return MockObject&T + * @phpstan-return Stub&T */ public function createStub($originalClassName) {} diff --git a/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php b/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php index a96aa0a..1fe31df 100644 --- a/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class AssertSameBooleanExpectedRuleTest extends \PHPStan\Testing\RuleTestCase +class AssertSameBooleanExpectedRuleTest extends RuleTestCase { protected function getRule(): Rule @@ -28,15 +29,15 @@ public function testRule(): void ], [ 'You should use assertTrue() instead of assertSame() when expecting "true"', - 14, + 26, ], [ - 'You should use assertFalse() instead of assertSame() when expecting "false"', - 17, + 'You should use assertTrue() instead of assertSame() when expecting "true"', + 74, ], [ - 'You should use assertTrue() instead of assertSame() when expecting "true"', - 26, + 'You should use assertFalse() instead of assertSame() when expecting "false"', + 75, ], ]); } diff --git a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php index dd96118..0898640 100644 --- a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php @@ -4,11 +4,12 @@ use PHPStan\Rules\Comparison\ImpossibleCheckTypeMethodCallRule; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class AssertSameMethodDifferentTypesRuleTest extends \PHPStan\Testing\RuleTestCase +class AssertSameMethodDifferentTypesRuleTest extends RuleTestCase { protected function getRule(): Rule @@ -42,11 +43,16 @@ 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.', 44, ], + [ + 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\'} and array{\'a\', \'b\'} will always evaluate to false.', + 45, + ], [ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and \'1\' will always evaluate to true.', 46, @@ -55,6 +61,10 @@ public function testRule(): void 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and \'2\' will always evaluate to false.', 47, ], + [ + 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\'} and array{\'a\', 1} will always evaluate to false.', + 51, + ], [ 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\', 2, 3.0} and array{\'a\', 1} will always evaluate to false.', 52, diff --git a/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php b/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php index 009a96d..1e802dc 100644 --- a/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class AssertSameNullExpectedRuleTest extends \PHPStan\Testing\RuleTestCase +class AssertSameNullExpectedRuleTest extends RuleTestCase { protected function getRule(): Rule @@ -24,11 +25,11 @@ public function testRule(): void ], [ 'You should use assertNull() instead of assertSame(null, $actual).', - 13, + 24, ], [ 'You should use assertNull() instead of assertSame(null, $actual).', - 24, + 60, ], ]); } diff --git a/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php index e55d3e2..cc65b27 100644 --- a/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php @@ -4,11 +4,12 @@ use PHPStan\Rules\Comparison\ImpossibleCheckTypeStaticMethodCallRule; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class AssertSameStaticMethodDifferentTypesRuleTest extends \PHPStan\Testing\RuleTestCase +class AssertSameStaticMethodDifferentTypesRuleTest extends RuleTestCase { protected function getRule(): Rule diff --git a/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php b/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php index 9a0caf9..32f564d 100644 --- a/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php +++ b/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class AssertSameWithCountRuleTest extends \PHPStan\Testing\RuleTestCase +class AssertSameWithCountRuleTest extends RuleTestCase { protected function getRule(): Rule diff --git a/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php new file mode 100644 index 0000000..32806ed --- /dev/null +++ b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php @@ -0,0 +1,61 @@ + + */ +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.', + ], + ]); + } + + /** + * @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 0000000..b886b46 --- /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 0000000..18cc12b --- /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 0000000..4c06546 --- /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/MockMethodCallRuleTest.php b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php index cd42678..c9c33e6 100644 --- a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php +++ b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php @@ -3,11 +3,13 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; +use function interface_exists; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class MockMethodCallRuleTest extends \PHPStan\Testing\RuleTestCase +class MockMethodCallRuleTest extends RuleTestCase { protected function getRule(): Rule @@ -17,7 +19,7 @@ protected function getRule(): Rule public function testRule(): void { - $this->analyse([__DIR__ . '/data/mock-method-call.php'], [ + $expectedErrors = [ [ 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.', 15, @@ -26,7 +28,16 @@ public function testRule(): void 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.', 20, ], - ]); + ]; + + if (interface_exists('PHPUnit\Framework\MockObject\Builder\InvocationStubber')) { + $expectedErrors[] = [ + 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.', + 36, + ]; + } + + $this->analyse([__DIR__ . '/data/mock-method-call.php'], $expectedErrors); } /** diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php new file mode 100644 index 0000000..e28fde1 --- /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 0000000..2926ec9 --- /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/ShouldCallParentMethodsRuleTest.php b/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php index 3f8d6e2..b378c67 100644 --- a/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php +++ b/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; /** - * @extends \PHPStan\Testing\RuleTestCase + * @extends RuleTestCase */ -class ShouldCallParentMethodsRuleTest extends \PHPStan\Testing\RuleTestCase +class ShouldCallParentMethodsRuleTest extends RuleTestCase { protected function getRule(): Rule diff --git a/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php new file mode 100644 index 0000000..11f9b90 --- /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 0000000..9154937 --- /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/assert-same-boolean-expected.php b/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php index 1158a57..dccd2ce 100644 --- a/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php +++ b/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php @@ -11,10 +11,10 @@ public function testAssertSameWithBooleanAsExpected() $this->assertSame(false, 'a'); $truish = true; - $this->assertSame($truish, true); + $this->assertSame($truish, true); // using variable is OK $falsish = false; - $this->assertSame($falsish, false); + $this->assertSame($falsish, false); // using variable is OK /** @var bool $a */ $a = null; @@ -26,4 +26,56 @@ public function testAssertSameIsDetectedWithDirectAssertAccess() \PHPUnit\Framework\Assert::assertSame(true, 'foo'); } + public function testConstants(): void + { + \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_TRUE, 'foo'); + \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_FALSE, 'foo'); + } + + private const TRUE = true; + private const FALSE = false; + + public function testClassConstants(): void + { + \PHPUnit\Framework\Assert::assertSame(self::TRUE, 'foo'); + \PHPUnit\Framework\Assert::assertSame(self::FALSE, 'foo'); + } + + public function returnBool(): bool + { + return true; + } + + /** + * @return true + */ + public function returnTrue(): bool + { + return true; + } + + /** + * @return false + */ + public function returnFalse(): bool + { + return false; + } + + public function testMethodCalls(): void + { + \PHPUnit\Framework\Assert::assertSame($this->returnTrue(), 'foo'); + \PHPUnit\Framework\Assert::assertSame($this->returnFalse(), 'foo'); + \PHPUnit\Framework\Assert::assertSame($this->returnBool(), 'foo'); + } + + public function testNonLowercase(): void + { + \PHPUnit\Framework\Assert::assertSame(True, 'foo'); + \PHPUnit\Framework\Assert::assertSame(False, 'foo'); + } + } + +const PHPSTAN_PHPUNIT_TRUE = true; +const PHPSTAN_PHPUNIT_FALSE = false; diff --git a/tests/Rules/PHPUnit/data/assert-same-null-expected.php b/tests/Rules/PHPUnit/data/assert-same-null-expected.php index 8c7be33..fedc4c9 100644 --- a/tests/Rules/PHPUnit/data/assert-same-null-expected.php +++ b/tests/Rules/PHPUnit/data/assert-same-null-expected.php @@ -10,7 +10,7 @@ public function testAssertSameWithNullAsExpected() $this->assertSame(null, 'a'); $a = null; - $this->assertSame($a, 'b'); + $this->assertSame($a, 'b'); // using variable is OK $this->assertSame('a', 'b'); // OK @@ -24,4 +24,42 @@ public function testAssertSameIsDetectedWithDirectAssertAccess() \PHPUnit\Framework\Assert::assertSame(null, 'foo'); } + public function testConstant(): void + { + \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_NULL, 'foo'); + } + + private const NULL = null; + + public function testClassConstant(): void + { + \PHPUnit\Framework\Assert::assertSame(self::NULL, 'foo'); + } + + public function returnNullable(): ?string + { + + } + + /** + * @return null + */ + public function returnNull() + { + return null; + } + + public function testMethodCalls(): void + { + \PHPUnit\Framework\Assert::assertSame($this->returnNull(), 'foo'); + \PHPUnit\Framework\Assert::assertSame($this->returnNullable(), 'foo'); + } + + public function testNonLowercase(): void + { + \PHPUnit\Framework\Assert::assertSame(Null, 'foo'); + } + } + +const PHPSTAN_PHPUNIT_NULL = null; diff --git a/tests/Rules/PHPUnit/data/assert-same.php b/tests/Rules/PHPUnit/data/assert-same.php index 48b81e4..41384e5 100644 --- a/tests/Rules/PHPUnit/data/assert-same.php +++ b/tests/Rules/PHPUnit/data/assert-same.php @@ -65,4 +65,18 @@ public function testOther() $foo->assertSame(); } + public function testStaticMethodReturnWithSameTypeIsNotReported() + { + $this->assertSame(self::createSomething('foo'), self::createSomething('foo')); + $this->assertNotSame(self::createSomething('bar'), self::createSomething('bar')); + } + + /** + * @return object + */ + private static function createSomething(string $what) + { + return new \stdClass(); + } + } diff --git a/tests/Rules/PHPUnit/data/bug-141.php b/tests/Rules/PHPUnit/data/bug-141.php new file mode 100644 index 0000000..3001104 --- /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 0000000..2d4e0ef --- /dev/null +++ b/tests/Rules/PHPUnit/data/class-coverage.php @@ -0,0 +1,59 @@ +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 0000000..77ed1ab --- /dev/null +++ b/tests/Rules/PHPUnit/data/method-coverage.php @@ -0,0 +1,87 @@ +method('doBadThing'); } + public function testGoodMethodOnStub() + { + $this->createStub(Bar::class)->method('doThing'); + } + + public function testBadMethodOnStub() + { + $this->createStub(Bar::class)->method('doBadThing'); + } + + public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock) + { + $mock->method('doFoo'); + } + } class Bar { @@ -41,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 c32b9d0..f2a254d 100644 --- a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php +++ b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\PHPUnit; use PHPStan\Testing\TypeInferenceTestCase; +use function function_exists; class AssertFunctionTypeSpecifyingExtensionTest extends TypeInferenceTestCase { @@ -19,8 +20,6 @@ public function dataFileAsserts(): iterable /** * @dataProvider dataFileAsserts - * @param string $assertType - * @param string $file * @param mixed ...$args */ public function testFileAsserts( diff --git a/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php b/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php index 6f6a1fc..e1841e0 100644 --- a/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php +++ b/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php @@ -15,8 +15,6 @@ public function dataFileAsserts(): iterable /** * @dataProvider dataFileAsserts - * @param string $assertType - * @param string $file * @param mixed ...$args */ public function testFileAsserts( diff --git a/tests/Type/PHPUnit/data/assert-function.php b/tests/Type/PHPUnit/data/assert-function.php index ebfeb93..117179a 100644 --- a/tests/Type/PHPUnit/data/assert-function.php +++ b/tests/Type/PHPUnit/data/assert-function.php @@ -4,6 +4,7 @@ use function PHPStan\Testing\assertType; use function PHPUnit\Framework\assertArrayHasKey; +use function PHPUnit\Framework\assertEmpty; use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertObjectHasAttribute; @@ -36,4 +37,10 @@ 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); + } + }