diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index dc841a4..2f19aba 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -5,7 +5,6 @@ on: - pull_request env: - COMPOSER_FLAGS: "--ansi --no-interaction --no-progress --prefer-dist" SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT: "1" jobs: @@ -13,6 +12,7 @@ jobs: name: "CI" runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} strategy: matrix: @@ -24,7 +24,10 @@ jobs: - "8.1" - "8.2" - "8.3" - - "8.4" + experimental: [false] + include: + - php-version: "8.4" + experimental: true steps: - name: "Checkout" @@ -49,9 +52,9 @@ jobs: - name: "Install latest dependencies" run: | - # Remove PHPStan as it requires a newer PHP - composer remove phpstan/phpstan phpstan/phpstan-strict-rules --dev --no-update composer update ${{ env.COMPOSER_FLAGS }} - name: "Run tests" - run: "vendor/bin/simple-phpunit --verbose" + run: | + vendor/bin/phpunit + vendor/bin/phpunit --testsuite phpstan diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 93bea17..f135b11 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -44,8 +44,5 @@ jobs: - name: "Install latest dependencies" run: "composer update ${{ env.COMPOSER_FLAGS }}" - - name: "Initialize PHPUnit sources" - run: "vendor/bin/simple-phpunit --filter NO_TEST_JUST_AUTOLOAD_THANKS" - - name: "Run PHPStan" run: "composer phpstan" diff --git a/composer.json b/composer.json index 8e78c8c..f81a2e4 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,13 @@ "php": "^7.2 || ^8.0" }, "require-dev": { - "symfony/phpunit-bridge": "^7", - "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8 || ^9", + "phpstan/phpstan": "^1.11.6", "phpstan/phpstan-strict-rules": "^1.1" }, + "conflict": { + "phpstan/phpstan": "<1.11.6" + }, "autoload": { "psr-4": { "Composer\\Pcre\\": "src" @@ -40,7 +43,7 @@ } }, "scripts": { - "test": "SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT=1 vendor/bin/simple-phpunit", - "phpstan": "phpstan analyse" + "test": "@php vendor/bin/phpunit", + "phpstan": "@php phpstan analyse" } } diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..282b8d4 --- /dev/null +++ b/extension.neon @@ -0,0 +1,16 @@ +# composer/pcre PHPStan extensions +# +# These can be reused by third party packages by including 'vendor/composer/pcre/extension.neon' +# in your phpstan config + +conditionalTags: + Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension: + phpstan.staticMethodParameterOutTypeExtension: %featureToggles.narrowPregMatches% + Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension: + phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension: %featureToggles.narrowPregMatches% + +services: + - + class: Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension + - + class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension diff --git a/phpstan.neon.dist b/phpstan.neon.dist index add4274..890f3bc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,9 +8,13 @@ parameters: treatPhpDocTypesAsCertain: false bootstrapFiles: - - tests/phpstan-locate-phpunit-autoloader.php + - vendor/autoload.php + + excludePaths: + - tests/PHPStanTests/nsrt/* includes: + - extension.neon - vendor/phpstan/phpstan/conf/bleedingEdge.neon - vendor/phpstan/phpstan-strict-rules/rules.neon - phpstan-baseline.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4a7697f..ea52b72 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,10 +5,15 @@ backupGlobals="false" colors="true" bootstrap="vendor/autoload.php" + defaultTestSuite="pcre" > - - tests + + tests/PregTests + tests/RegexTests + + + tests/PHPStanTests diff --git a/src/PHPStan/PregMatchFlags.php b/src/PHPStan/PregMatchFlags.php new file mode 100644 index 0000000..9cd3598 --- /dev/null +++ b/src/PHPStan/PregMatchFlags.php @@ -0,0 +1,36 @@ +getType($flagsArg->value); + + $constantScalars = $flagsType->getConstantScalarValues(); + if ($constantScalars === []) { + return null; + } + + $internalFlagsTypes = []; + foreach ($flagsType->getConstantScalarValues() as $constantScalarValue) { + if (!is_int($constantScalarValue)) { + return null; + } + + $internalFlagsTypes[] = new ConstantIntegerType($constantScalarValue | PREG_UNMATCHED_AS_NULL); + } + return TypeCombinator::union(...$internalFlagsTypes); + } +} diff --git a/src/PHPStan/PregMatchParameterOutTypeExtension.php b/src/PHPStan/PregMatchParameterOutTypeExtension.php new file mode 100644 index 0000000..7793844 --- /dev/null +++ b/src/PHPStan/PregMatchParameterOutTypeExtension.php @@ -0,0 +1,59 @@ +regexShapeMatcher = $regexShapeMatcher; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return + $methodReflection->getDeclaringClass()->getName() === Preg::class + && $methodReflection->getName() === 'match' + && $parameter->getName() === 'matches'; + } + + public function getParameterOutTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return null; + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + if ($flagsType === null) { + return null; + } + $patternType = $scope->getType($patternArg->value); + + return $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createMaybe()); + } + +} diff --git a/src/PHPStan/PregMatchTypeSpecifyingExtension.php b/src/PHPStan/PregMatchTypeSpecifyingExtension.php new file mode 100644 index 0000000..63559cc --- /dev/null +++ b/src/PHPStan/PregMatchTypeSpecifyingExtension.php @@ -0,0 +1,88 @@ +regexShapeMatcher = $regexShapeMatcher; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return Preg::class; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection, StaticCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'match' && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, StaticCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return new SpecifiedTypes(); + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + if ($flagsType === null) { + return new SpecifiedTypes(); + } + $patternType = $scope->getType($patternArg->value); + + $matchedType = $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createFromBoolean($context->true())); + if ($matchedType === null) { + return new SpecifiedTypes(); + } + + $overwrite = false; + if ($context->false()) { + $overwrite = true; + $context = $context->negate(); + } + + return $this->typeSpecifier->create( + $matchesArg->value, + $matchedType, + $context, + $overwrite, + $scope, + $node + ); + } +} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index 4db4409..20557ab 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -51,16 +51,12 @@ protected function expectPcreException(string $pattern, ?string $error = null): // Only use a message if the error can be reliably determined if (PHP_VERSION_ID >= 80000) { $error = 'Internal error'; - } elseif (PHP_VERSION_ID >= 70201) { + } else { $error = 'PREG_INTERNAL_ERROR'; } } - if (null !== $error) { - $message = sprintf('%s: failed executing "%s": %s', $this->pregFunction, $pattern, $error); - } else { - $message = null; - } + $message = sprintf('%s: failed executing "%s": %s', $this->pregFunction, $pattern, $error); $this->doExpectException('Composer\Pcre\PcreException', $message); } diff --git a/tests/PHPStanTests/TypeInferenceTest.php b/tests/PHPStanTests/TypeInferenceTest.php new file mode 100644 index 0000000..b669f0a --- /dev/null +++ b/tests/PHPStanTests/TypeInferenceTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre\PHPStanTests; + +use PHPStan\Testing\TypeInferenceTestCase; + +/** + * Run with "vendor/bin/phpunit --testsuite phpstan" + * + * This is excluded by default to avoid side effects with the library tests + * + * @group phpstan + */ +class TypeInferenceTest extends TypeInferenceTestCase +{ + /** + * @return iterable + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypesFromDirectory(__DIR__ . '/nsrt'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + 'phar://' . __DIR__ . '/../../vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon', + __DIR__ . '/../../extension.neon', + ]; + } +} diff --git a/tests/PHPStanTests/nsrt/preg-match.php b/tests/PHPStanTests/nsrt/preg-match.php new file mode 100644 index 0000000..a217b57 --- /dev/null +++ b/tests/PHPStanTests/nsrt/preg-match.php @@ -0,0 +1,52 @@ += 80000 && false !== strpos((string) $dir, 'phpunit-9')) { - break; - } - if (PHP_VERSION_ID < 80000 && false !== strpos((string) $dir, 'phpunit-8')) { - break; - } -} - -if (null === $bestDirFound) { - echo 'Run "composer test" to initialize PHPUnit sources before running PHPStan'.PHP_EOL; - exit(1); -} - -include $bestDirFound.'/vendor/autoload.php';