diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 80abad6e94..1aa11917f7 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -407,6 +407,10 @@ jobs: cd e2e/composer-version-config composer install ../../bin/phpstan analyze test.php --level=0 + - script: | + cd e2e/version-compare-phpversionid + composer install + ../../bin/phpstan analyze narrow-phpversionid-on-version-compare.php --level=0 - script: | cd e2e/bug13425 timeout 15 ../bashunit -a exit_code "1" "../../bin/phpstan analyze src/ plugins/" diff --git a/e2e/version-compare-phpversionid/.gitignore b/e2e/version-compare-phpversionid/.gitignore new file mode 100644 index 0000000000..3a9875b460 --- /dev/null +++ b/e2e/version-compare-phpversionid/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/version-compare-phpversionid/composer.json b/e2e/version-compare-phpversionid/composer.json new file mode 100644 index 0000000000..e5f83a420c --- /dev/null +++ b/e2e/version-compare-phpversionid/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8" + } +} diff --git a/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare-global-scope.php b/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare-global-scope.php new file mode 100644 index 0000000000..8d610f85ea --- /dev/null +++ b/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare-global-scope.php @@ -0,0 +1,21 @@ +', $x); + + if ( + version_compare( PHP_VERSION, '8.4', '<' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>', $x); + } +} + diff --git a/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare.php b/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare.php new file mode 100644 index 0000000000..e680fd0144 --- /dev/null +++ b/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare.php @@ -0,0 +1,164 @@ + 80599) { + return; + } + + // lower limit inferred from composer.json + $x = PHP_VERSION_ID; + assertType('int<80000, 80599>', $x); + + if ( + version_compare( PHP_VERSION, '8.4', '<' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'lt' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '<=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80400>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'le' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80400>', $x); + } +} + +function greater(): void +{ + if ( + version_compare( PHP_VERSION, '8.4', '>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80401, max>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'gt' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80401, max>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '>=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80400, max>', $x); + } + if ( + version_compare( PHP_VERSION, '8.4', 'ge' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80400, max>', $x); + } +} + +function equal(): void +{ + if ( + version_compare( PHP_VERSION, '8.4', '=' ) + ) { + $x = PHP_VERSION_ID; + assertType('80400', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '==' ) + ) { + $x = PHP_VERSION_ID; + assertType('80400', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'eq' ) + ) { + $x = PHP_VERSION_ID; + assertType('80400', $x); + } +} + + +function not(): void +{ + if ( + version_compare( PHP_VERSION, '8.4', '!=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int|int<80401, max>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '<>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int|int<80401, max>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'ne' ) + ) { + $x = PHP_VERSION_ID; + assertType('int|int<80401, max>', $x); + } +} + +function inverseOperandLower(): void +{ + if ( + version_compare( '8.3.12', PHP_VERSION, '<' ) + ) { + $x = PHP_VERSION_ID; + assertType('int', $x); + } + + if ( + version_compare( '8.3.12', PHP_VERSION, '>=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80312, max>', $x); + } + + if ( + version_compare( '8.3.12', PHP_VERSION, '>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80313, max>', $x); + } +} + +function narrow(): void { + if (PHP_VERSION_ID < 80000) { + return; + } + + if ( + version_compare( PHP_VERSION, '8.4', '<' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>', $x); + } +} diff --git a/src/Php/SimplePhpVersionParser.php b/src/Php/SimplePhpVersionParser.php new file mode 100644 index 0000000000..01c9e9dce5 --- /dev/null +++ b/src/Php/SimplePhpVersionParser.php @@ -0,0 +1,26 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return strtolower($functionReflection->getName()) === 'version_compare' && $context->true(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$node->name instanceof Name) { + return new SpecifiedTypes(); + } + + $args = $node->getArgs(); + if (count($args) < 2) { + return new SpecifiedTypes(); + } + + $version1 = $args[0]->value; + $version2 = $args[1]->value; + + if ( + $version1 instanceof ConstFetch + && $version1->name->name === 'PHP_VERSION' + && $version2 instanceof String_ + ) { + $integerVersionRange = $this->getVersionCompareType($version2->value, isset($args[2]) ? $args[2]->value : null, $scope); + + if ($integerVersionRange !== null) { + $narrowedVersion = TypeCombinator::intersect($scope->getPhpVersion()->getType(), $integerVersionRange); + return $this->typeSpecifier->create( + new ConstFetch(new Name('\\PHP_VERSION_ID')), + $narrowedVersion, + $context, + $scope, + ); + } + } + + if ( + $version2 instanceof ConstFetch + && $version2->name->name === 'PHP_VERSION' + && $version1 instanceof String_ + ) { + $integerVersionRange = $this->getVersionCompareType($version1->value, isset($args[2]) ? $args[2]->value : null, $scope); + if ($integerVersionRange !== null) { + $narrowedVersion = TypeCombinator::intersect($scope->getPhpVersion()->getType(), $integerVersionRange); + return $this->typeSpecifier->create( + new ConstFetch(new Name('\\PHP_VERSION_ID')), + $narrowedVersion, + $context, + $scope, + ); + } + } + + return new SpecifiedTypes(); + } + + private function getVersionCompareType(string $value, ?Expr $operator, Scope $scope): ?Type + { + $parsedVersion = SimplePhpVersionParser::parseVersion($value); + if ($parsedVersion === null) { + return null; + } + + if ($operator !== null) { + $operators = $scope->getType($operator)->getConstantStrings(); + if (count($operators) !== 1) { + return null; + } + + $operatorString = $operators[0]->getValue(); + } else { + $operatorString = '<'; + } + + if (!in_array($operatorString, VersionCompareFunctionDynamicReturnTypeExtension::VALID_OPERATORS, true)) { + return null; + } + + if (in_array($operatorString, ['<', 'lt'], true)) { + return IntegerRangeType::fromInterval(null, $parsedVersion->getVersionId() - 1); + } + if (in_array($operatorString, ['<=', 'le'], true)) { + return IntegerRangeType::fromInterval(null, $parsedVersion->getVersionId()); + } + + if (in_array($operatorString, ['>', 'gt'], true)) { + return IntegerRangeType::fromInterval($parsedVersion->getVersionId() + 1, null); + } + if (in_array($operatorString, ['>=', 'ge'], true)) { + return IntegerRangeType::fromInterval($parsedVersion->getVersionId(), null); + } + + if ( + in_array($operatorString, ['==', '=', 'eq'], true) + ) { + return new ConstantIntegerType($parsedVersion->getVersionId()); + } + + return TypeCombinator::remove(new IntegerType(), new ConstantIntegerType($parsedVersion->getVersionId())); + } + +} diff --git a/tests/PHPStan/Analyser/data/bug-5639.php b/tests/PHPStan/Analyser/data/bug-5639.php index c0eeed3370..740abfb915 100644 --- a/tests/PHPStan/Analyser/data/bug-5639.php +++ b/tests/PHPStan/Analyser/data/bug-5639.php @@ -2,7 +2,7 @@ namespace Bug5639; -if (\version_compare(\PHP_VERSION, '7.0', '<')) +if (\version_compare(\PHP_VERSION, '7.0', '<')) // @phpstan-ignore function.impossibleType { class Foo extends \Exception { function __toString(): string {