diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9c134d199c..7577e5c04f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -292,6 +292,48 @@ jobs: - script: | cd e2e/bug-11819 ../../bin/phpstan + - script: | + cd e2e/composer-max-version + composer install + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-min-max-version + composer install + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-min-open-end-version + composer install + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-min-version-v5 + composer install --ignore-platform-reqs + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-min-version-v7 + composer install --ignore-platform-reqs + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-min-version + composer install + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-no-versions + composer install + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-version-config-invalid + OUTPUT=$(../bashunit -a exit_code "1" ../../bin/phpstan) + echo "$OUTPUT" + ../bashunit -a contains 'Invalid configuration' "$OUTPUT" + ../bashunit -a contains 'Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.' "$OUTPUT" + - script: | + cd e2e/composer-version-config-patch + composer install --ignore-platform-reqs + ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/composer-version-config + composer install + ../../bin/phpstan analyze test.php --level=0 steps: - name: "Checkout" @@ -308,5 +350,8 @@ jobs: - name: "Install dependencies" run: "composer install --no-interaction --no-progress" + - name: "Install bashunit" + run: "curl -s https://bashunit.typeddevs.com/install.sh | bash -s e2e/ 0.17.0" + - name: "Test" run: ${{ matrix.script }} diff --git a/conf/config.neon b/conf/config.neon index 7d1bf16616..df5c15ddec 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -339,7 +339,13 @@ services: - class: PHPStan\Php\PhpVersionFactoryFactory arguments: - versionId: %phpVersion% + phpVersion: %phpVersion% + composerAutoloaderProjectPaths: %composerAutoloaderProjectPaths% + + - + class: PHPStan\Php\ComposerPhpVersionFactory + arguments: + phpVersion: %phpVersion% composerAutoloaderProjectPaths: %composerAutoloaderProjectPaths% - diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index d6e4a34882..6c7f9c40e6 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -79,7 +79,13 @@ parametersSchema: minimumNumberOfJobsPerProcess: int(), buffer: int() ]) - phpVersion: schema(anyOf(schema(int(), min(70100), max(80499))), nullable()) + phpVersion: schema(anyOf( + schema(int(), min(70100), max(80499)), + structure([ + min: schema(int(), min(70100), max(80499)), + max: schema(int(), min(70100), max(80499)) + ]) + ), nullable()) polluteScopeWithLoopInitialAssignments: bool() polluteScopeWithAlwaysIterableForeach: bool() polluteScopeWithBlock: bool() diff --git a/e2e/composer-max-version/.gitignore b/e2e/composer-max-version/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-max-version/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-max-version/composer.json b/e2e/composer-max-version/composer.json new file mode 100644 index 0000000000..4d4ca141ef --- /dev/null +++ b/e2e/composer-max-version/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "<=8.3" + } +} diff --git a/e2e/composer-max-version/test.php b/e2e/composer-max-version/test.php new file mode 100644 index 0000000000..038f559122 --- /dev/null +++ b/e2e/composer-max-version/test.php @@ -0,0 +1,10 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('int<5, 8>', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); + +\PHPStan\Testing\assertType('-1|0|1', version_compare(PHP_VERSION, '7.0.0')); +\PHPStan\Testing\assertType('bool', version_compare(PHP_VERSION, '7.0.0', '<')); +\PHPStan\Testing\assertType('bool', version_compare(PHP_VERSION, '7.0.0', '>')); diff --git a/e2e/composer-min-max-version/.gitignore b/e2e/composer-min-max-version/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-min-max-version/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-min-max-version/composer.json b/e2e/composer-min-max-version/composer.json new file mode 100644 index 0000000000..869fd2ce42 --- /dev/null +++ b/e2e/composer-min-max-version/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": ">=8.1, <=8.2.99" + } +} diff --git a/e2e/composer-min-max-version/test.php b/e2e/composer-min-max-version/test.php new file mode 100644 index 0000000000..28d770f3bb --- /dev/null +++ b/e2e/composer-min-max-version/test.php @@ -0,0 +1,10 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('8', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<1, 2>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); + +\PHPStan\Testing\assertType('1', version_compare(PHP_VERSION, '7.0.0')); +\PHPStan\Testing\assertType('false', version_compare(PHP_VERSION, '7.0.0', '<')); +\PHPStan\Testing\assertType('true', version_compare(PHP_VERSION, '7.0.0', '>')); diff --git a/e2e/composer-min-open-end-version/.gitignore b/e2e/composer-min-open-end-version/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-min-open-end-version/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-min-open-end-version/composer.json b/e2e/composer-min-open-end-version/composer.json new file mode 100644 index 0000000000..b6303c6b77 --- /dev/null +++ b/e2e/composer-min-open-end-version/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": ">= 8.1" + } +} diff --git a/e2e/composer-min-open-end-version/test.php b/e2e/composer-min-open-end-version/test.php new file mode 100644 index 0000000000..d4d06a34eb --- /dev/null +++ b/e2e/composer-min-open-end-version/test.php @@ -0,0 +1,6 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('8', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<1, 4>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-min-version-v5/.gitignore b/e2e/composer-min-version-v5/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-min-version-v5/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-min-version-v5/composer.json b/e2e/composer-min-version-v5/composer.json new file mode 100644 index 0000000000..b73464d219 --- /dev/null +++ b/e2e/composer-min-version-v5/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^5.6" + } +} diff --git a/e2e/composer-min-version-v5/test.php b/e2e/composer-min-version-v5/test.php new file mode 100644 index 0000000000..2f652079a6 --- /dev/null +++ b/e2e/composer-min-version-v5/test.php @@ -0,0 +1,6 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('5', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('6', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, 99>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-min-version-v7/.gitignore b/e2e/composer-min-version-v7/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-min-version-v7/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-min-version-v7/composer.json b/e2e/composer-min-version-v7/composer.json new file mode 100644 index 0000000000..9f9b263871 --- /dev/null +++ b/e2e/composer-min-version-v7/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^7" + } +} diff --git a/e2e/composer-min-version-v7/test.php b/e2e/composer-min-version-v7/test.php new file mode 100644 index 0000000000..e8876bd78f --- /dev/null +++ b/e2e/composer-min-version-v7/test.php @@ -0,0 +1,6 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('7', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<0, 4>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-min-version/.gitignore b/e2e/composer-min-version/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-min-version/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-min-version/composer.json b/e2e/composer-min-version/composer.json new file mode 100644 index 0000000000..9be64619f1 --- /dev/null +++ b/e2e/composer-min-version/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8.1" + } +} diff --git a/e2e/composer-min-version/test.php b/e2e/composer-min-version/test.php new file mode 100644 index 0000000000..d4d06a34eb --- /dev/null +++ b/e2e/composer-min-version/test.php @@ -0,0 +1,6 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('8', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<1, 4>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-no-versions/.gitignore b/e2e/composer-no-versions/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-no-versions/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-no-versions/composer.json b/e2e/composer-no-versions/composer.json new file mode 100644 index 0000000000..2c63c08510 --- /dev/null +++ b/e2e/composer-no-versions/composer.json @@ -0,0 +1,2 @@ +{ +} diff --git a/e2e/composer-no-versions/test.php b/e2e/composer-no-versions/test.php new file mode 100644 index 0000000000..28c8a3183b --- /dev/null +++ b/e2e/composer-no-versions/test.php @@ -0,0 +1,6 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('int<5, 8>', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-version-config-invalid/phpstan.neon b/e2e/composer-version-config-invalid/phpstan.neon new file mode 100644 index 0000000000..96977def5f --- /dev/null +++ b/e2e/composer-version-config-invalid/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + phpVersion: + min: 80303 + max: 80104 + diff --git a/e2e/composer-version-config-patch/.gitignore b/e2e/composer-version-config-patch/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-version-config-patch/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-version-config-patch/composer.json b/e2e/composer-version-config-patch/composer.json new file mode 100644 index 0000000000..d6103988c8 --- /dev/null +++ b/e2e/composer-version-config-patch/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": ">=8.0.2, <8.0.15" + } +} diff --git a/e2e/composer-version-config-patch/test.php b/e2e/composer-version-config-patch/test.php new file mode 100644 index 0000000000..3f201eadab --- /dev/null +++ b/e2e/composer-version-config-patch/test.php @@ -0,0 +1,7 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('8', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('0', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<2, 15>', PHP_RELEASE_VERSION); diff --git a/e2e/composer-version-config/.gitignore b/e2e/composer-version-config/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/composer-version-config/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-version-config/composer.json b/e2e/composer-version-config/composer.json new file mode 100644 index 0000000000..2da0adaf1c --- /dev/null +++ b/e2e/composer-version-config/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8.0" + } +} diff --git a/e2e/composer-version-config/phpstan.neon b/e2e/composer-version-config/phpstan.neon new file mode 100644 index 0000000000..003e5e1484 --- /dev/null +++ b/e2e/composer-version-config/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + phpVersion: + min: 80103 + max: 80304 diff --git a/e2e/composer-version-config/test.php b/e2e/composer-version-config/test.php new file mode 100644 index 0000000000..a9afaa4b65 --- /dev/null +++ b/e2e/composer-version-config/test.php @@ -0,0 +1,11 @@ +', PHP_VERSION_ID); +\PHPStan\Testing\assertType('8', PHP_MAJOR_VERSION); +\PHPStan\Testing\assertType('int<1, 3>', PHP_MINOR_VERSION); +\PHPStan\Testing\assertType('int<0, max>', PHP_RELEASE_VERSION); + +\PHPStan\Testing\assertType('1', version_compare(PHP_VERSION, '7.0.0')); +\PHPStan\Testing\assertType('false', version_compare(PHP_VERSION, '7.0.0', '<')); +\PHPStan\Testing\assertType('true', version_compare(PHP_VERSION, '7.0.0', '>')); diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index b209533fac..8176fe8abc 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Name; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\NamespaceAnswerer; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\ReflectionProvider\ReflectionProviderProvider; @@ -20,6 +21,7 @@ use PHPStan\Type\UnionType; use function array_key_exists; use function in_array; +use function max; use function sprintf; use const INF; use const NAN; @@ -34,7 +36,12 @@ final class ConstantResolver /** * @param string[] $dynamicConstantNames */ - public function __construct(private ReflectionProviderProvider $reflectionProviderProvider, private array $dynamicConstantNames) + public function __construct( + private ReflectionProviderProvider $reflectionProviderProvider, + private array $dynamicConstantNames, + private ?PhpVersion $composerMinPhpVersion, + private ?PhpVersion $composerMaxPhpVersion, + ) { } @@ -77,16 +84,60 @@ public function resolvePredefinedConstant(string $resolvedConstantName): ?Type ]); } if ($resolvedConstantName === 'PHP_MAJOR_VERSION') { - return IntegerRangeType::fromInterval(5, null); + $minMajor = 5; + $maxMajor = null; + + if ($this->composerMinPhpVersion !== null) { + $minMajor = max($minMajor, $this->composerMinPhpVersion->getMajorVersionId()); + } + if ($this->composerMaxPhpVersion !== null) { + $maxMajor = $this->composerMaxPhpVersion->getMajorVersionId(); + } + + return $this->createInteger($minMajor, $maxMajor); } if ($resolvedConstantName === 'PHP_MINOR_VERSION') { - return IntegerRangeType::fromInterval(0, null); + $minMinor = 0; + $maxMinor = null; + + if ( + $this->composerMinPhpVersion !== null + && $this->composerMaxPhpVersion !== null + && $this->composerMaxPhpVersion->getMajorVersionId() === $this->composerMinPhpVersion->getMajorVersionId() + ) { + $minMinor = $this->composerMinPhpVersion->getMinorVersionId(); + $maxMinor = $this->composerMaxPhpVersion->getMinorVersionId(); + } + + return $this->createInteger($minMinor, $maxMinor); } if ($resolvedConstantName === 'PHP_RELEASE_VERSION') { - return IntegerRangeType::fromInterval(0, null); + $minRelease = 0; + $maxRelease = null; + + if ( + $this->composerMinPhpVersion !== null + && $this->composerMaxPhpVersion !== null + && $this->composerMaxPhpVersion->getMajorVersionId() === $this->composerMinPhpVersion->getMajorVersionId() + && $this->composerMaxPhpVersion->getMinorVersionId() === $this->composerMinPhpVersion->getMinorVersionId() + ) { + $minRelease = $this->composerMinPhpVersion->getPatchVersionId(); + $maxRelease = $this->composerMaxPhpVersion->getPatchVersionId(); + } + + return $this->createInteger($minRelease, $maxRelease); } if ($resolvedConstantName === 'PHP_VERSION_ID') { - return IntegerRangeType::fromInterval(50207, null); + $minVersion = 50207; + $maxVersion = null; + if ($this->composerMinPhpVersion !== null) { + $minVersion = max($minVersion, $this->composerMinPhpVersion->getVersionId()); + } + if ($this->composerMaxPhpVersion !== null) { + $maxVersion = $this->composerMaxPhpVersion->getVersionId(); + } + + return $this->createInteger($minVersion, $maxVersion); } if ($resolvedConstantName === 'PHP_ZTS') { return new UnionType([ @@ -325,6 +376,14 @@ public function resolveClassConstantType(string $className, string $constantName return $constantType; } + private function createInteger(?int $min, ?int $max): Type + { + if ($min !== null && $min === $max) { + return new ConstantIntegerType($min); + } + return IntegerRangeType::fromInterval($min, $max); + } + private function getReflectionProvider(): ReflectionProvider { return $this->reflectionProviderProvider->getReflectionProvider(); diff --git a/src/Analyser/ConstantResolverFactory.php b/src/Analyser/ConstantResolverFactory.php index 67a98408a0..f111da14ec 100644 --- a/src/Analyser/ConstantResolverFactory.php +++ b/src/Analyser/ConstantResolverFactory.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PHPStan\DependencyInjection\Container; +use PHPStan\Php\ComposerPhpVersionFactory; use PHPStan\Reflection\ReflectionProvider\ReflectionProviderProvider; final class ConstantResolverFactory @@ -17,9 +18,13 @@ public function __construct( public function create(): ConstantResolver { + $composerFactory = $this->container->getByType(ComposerPhpVersionFactory::class); + return new ConstantResolver( $this->reflectionProviderProvider, $this->container->getParameter('dynamicConstantNames'), + $composerFactory->getMinVersion(), + $composerFactory->getMaxVersion(), ); } diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 7ac72d4fc4..75c79d6678 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -32,6 +32,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; use function array_diff_key; +use function array_key_exists; use function array_map; use function array_merge; use function array_unique; @@ -310,6 +311,18 @@ private function validateParameters(array $parameters, array $parametersSchema): $context->path = ['parameters']; }; $processor->process($schema, $parameters); + + if ( + !array_key_exists('phpVersion', $parameters) + || !is_array($parameters['phpVersion'])) { + return; + } + + $phpVersion = $parameters['phpVersion']; + + if ($phpVersion['max'] < $phpVersion['min']) { + throw new InvalidPhpVersionException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.'); + } } /** diff --git a/src/DependencyInjection/InvalidPhpVersionException.php b/src/DependencyInjection/InvalidPhpVersionException.php new file mode 100644 index 0000000000..f9b41690a3 --- /dev/null +++ b/src/DependencyInjection/InvalidPhpVersionException.php @@ -0,0 +1,10 @@ +initialized = true; + + $phpVersion = $this->phpVersion; + + if (is_int($phpVersion)) { + throw new ShouldNotHappenException(); + } + + if (is_array($phpVersion)) { + if ($phpVersion['max'] < $phpVersion['min']) { + throw new ShouldNotHappenException('Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.'); + } + + $this->minVersion = new PhpVersion($phpVersion['min']); + $this->maxVersion = new PhpVersion($phpVersion['max']); + + return; + } + + // don't limit minVersion... PHPStan can analyze even PHP5 + $this->maxVersion = new PhpVersion(PhpVersionFactory::MAX_PHP_VERSION); + + // fallback to composer.json based php-version constraint + $composerPhpVersion = $this->getComposerRequireVersion(); + if ($composerPhpVersion === null) { + return; + } + + $parser = new VersionParser(); + $constraint = $parser->parseConstraints($composerPhpVersion); + + if (!$constraint->getLowerBound()->isZero()) { + $minVersion = $this->buildVersion($constraint->getLowerBound()->getVersion(), false); + + if ($minVersion !== null) { + $this->minVersion = new PhpVersion($minVersion->getVersionId()); + } + } + if ($constraint->getUpperBound()->isPositiveInfinity()) { + return; + } + + $this->maxVersion = $this->buildVersion($constraint->getUpperBound()->getVersion(), true); + } + + public function getMinVersion(): ?PhpVersion + { + if (is_int($this->phpVersion)) { + return null; + } + + if ($this->initialized === false) { + $this->initializeVersions(); + } + + return $this->minVersion; + } + + public function getMaxVersion(): ?PhpVersion + { + if (is_int($this->phpVersion)) { + return null; + } + + if ($this->initialized === false) { + $this->initializeVersions(); + } + + return $this->maxVersion; + } + + private function getComposerRequireVersion(): ?string + { + $composerPhpVersion = null; + if (count($this->composerAutoloaderProjectPaths) > 0) { + $composerJsonPath = end($this->composerAutoloaderProjectPaths) . '/composer.json'; + if (is_file($composerJsonPath)) { + try { + $composerJsonContents = FileReader::read($composerJsonPath); + $composer = Json::decode($composerJsonContents, Json::FORCE_ARRAY); + $requiredVersion = $composer['require']['php'] ?? null; + if (is_string($requiredVersion)) { + $composerPhpVersion = $requiredVersion; + } + } catch (CouldNotReadFileException | JsonException) { + // pass + } + } + } + return $composerPhpVersion; + } + + private function buildVersion(string $version, bool $isMaxVersion): ?PhpVersion + { + $matches = Strings::match($version, '#^(\d+)\.(\d+)(?:\.(\d+))?#'); + if ($matches === null) { + return null; + } + + $major = $matches[1]; + $minor = $matches[2]; + $patch = $matches[3] ?? 0; + $versionId = (int) sprintf('%d%02d%02d', $major, $minor, $patch); + + if ($isMaxVersion && $version === '6.0.0.0-dev') { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP5_VERSION); + } elseif ($isMaxVersion && $version === '8.0.0.0-dev') { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP7_VERSION); + } else { + $versionId = min($versionId, PhpVersionFactory::MAX_PHP_VERSION); + } + + return new PhpVersion($versionId); + } + +} diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index fcd6871c39..83ca1245b5 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -41,11 +41,26 @@ public function getVersionId(): int return $this->versionId; } + public function getMajorVersionId(): int + { + return (int) floor($this->versionId / 10000); + } + + public function getMinorVersionId(): int + { + return (int) floor(($this->versionId % 10000) / 100); + } + + public function getPatchVersionId(): int + { + return (int) floor($this->versionId % 100); + } + public function getVersionString(): string { - $first = (int) floor($this->versionId / 10000); - $second = (int) floor(($this->versionId % 10000) / 100); - $third = (int) floor($this->versionId % 100); + $first = $this->getMajorVersionId(); + $second = $this->getMinorVersionId(); + $third = $this->getPatchVersionId(); return $first . '.' . $second . ($third !== 0 ? '.' . $third : ''); } diff --git a/src/Php/PhpVersionFactory.php b/src/Php/PhpVersionFactory.php index d926420e77..bd1bfcabf5 100644 --- a/src/Php/PhpVersionFactory.php +++ b/src/Php/PhpVersionFactory.php @@ -10,6 +10,11 @@ final class PhpVersionFactory { + public const MIN_PHP_VERSION = 70100; + public const MAX_PHP_VERSION = 80499; + public const MAX_PHP5_VERSION = 50699; + public const MAX_PHP7_VERSION = 70499; + public function __construct( private ?int $versionId, private ?string $composerPhpVersion, @@ -25,8 +30,8 @@ public function create(): PhpVersion } elseif ($this->composerPhpVersion !== null) { $parts = explode('.', $this->composerPhpVersion); $tmp = (int) $parts[0] * 10000 + (int) ($parts[1] ?? 0) * 100 + (int) ($parts[2] ?? 0); - $tmp = max($tmp, 70100); - $versionId = min($tmp, 80499); + $tmp = max($tmp, self::MIN_PHP_VERSION); + $versionId = min($tmp, self::MAX_PHP_VERSION); $source = PhpVersion::SOURCE_COMPOSER_PLATFORM_PHP; } else { $versionId = PHP_VERSION_ID; diff --git a/src/Php/PhpVersionFactoryFactory.php b/src/Php/PhpVersionFactoryFactory.php index c578ef06fc..0190ca0e82 100644 --- a/src/Php/PhpVersionFactoryFactory.php +++ b/src/Php/PhpVersionFactoryFactory.php @@ -8,17 +8,20 @@ use PHPStan\File\FileReader; use function count; use function end; +use function is_array; use function is_file; +use function is_int; use function is_string; final class PhpVersionFactoryFactory { /** + * @param int|array{min: int, max: int}|null $phpVersion * @param string[] $composerAutoloaderProjectPaths */ public function __construct( - private ?int $versionId, + private int|array|null $phpVersion, private array $composerAutoloaderProjectPaths, ) { @@ -43,7 +46,17 @@ public function create(): PhpVersionFactory } } - return new PhpVersionFactory($this->versionId, $composerPhpVersion); + $versionId = null; + + if (is_int($this->phpVersion)) { + $versionId = $this->phpVersion; + } + + if (is_array($this->phpVersion)) { + $versionId = $this->phpVersion['min']; + } + + return new PhpVersionFactory($versionId, $composerPhpVersion); } } diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index 87d5bab299..aee824bfbc 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -137,7 +137,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider } $reflectionProviderProvider = new DirectReflectionProviderProvider($reflectionProvider); - $constantResolver = new ConstantResolver($reflectionProviderProvider, $dynamicConstantNames); + $constantResolver = new ConstantResolver($reflectionProviderProvider, $dynamicConstantNames, null, null); $initializerExprTypeResolver = new InitializerExprTypeResolver( $constantResolver, diff --git a/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php index 9a5592bf40..d79ebf0706 100644 --- a/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php @@ -2,12 +2,15 @@ namespace PHPStan\Type\Php; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; +use PHPStan\Php\ComposerPhpVersionFactory; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -18,6 +21,10 @@ final class VersionCompareFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private ComposerPhpVersionFactory $composerPhpVersionFactory) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'version_compare'; @@ -29,19 +36,20 @@ public function getTypeFromFunctionCall( Scope $scope, ): ?Type { - if (count($functionCall->getArgs()) < 2) { + $args = $functionCall->getArgs(); + if (count($args) < 2) { return null; } - $version1Strings = $scope->getType($functionCall->getArgs()[0]->value)->getConstantStrings(); - $version2Strings = $scope->getType($functionCall->getArgs()[1]->value)->getConstantStrings(); + $version1Strings = $this->getVersionStrings($args[0]->value, $scope); + $version2Strings = $this->getVersionStrings($args[1]->value, $scope); $counts = [ count($version1Strings), count($version2Strings), ]; - if (isset($functionCall->getArgs()[2])) { - $operatorStrings = $scope->getType($functionCall->getArgs()[2]->value)->getConstantStrings(); + if (isset($args[2])) { + $operatorStrings = $scope->getType($args[2]->value)->getConstantStrings(); $counts[] = count($operatorStrings); $returnType = new BooleanType(); } else { @@ -77,4 +85,24 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...$types); } + /** + * @return ConstantStringType[] + */ + private function getVersionStrings(Expr $expr, Scope $scope): array + { + if ( + $expr instanceof Expr\ConstFetch + && $expr->name->toString() === 'PHP_VERSION' + && $this->composerPhpVersionFactory->getMinVersion() !== null + && $this->composerPhpVersionFactory->getMaxVersion() !== null + ) { + return [ + new ConstantStringType($this->composerPhpVersionFactory->getMinVersion()->getVersionString()), + new ConstantStringType($this->composerPhpVersionFactory->getMaxVersion()->getVersionString()), + ]; + } + + return $scope->getType($expr)->getConstantStrings(); + } + }