diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 99db5336ea1..217fdc83164 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -2042,6 +2042,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 assert 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 00000000000..3a9875b460f --- /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 00000000000..e5f83a420c5 --- /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 00000000000..8d610f85ea3 --- /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 00000000000..643829eb6e1 --- /dev/null +++ b/e2e/version-compare-phpversionid/narrow-phpversionid-on-version-compare.php @@ -0,0 +1,182 @@ + 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 +{ + // add a upper bound, so we don't need to adjust + // the test when PHPStan adds support for PHP8.6+ + if (PHP_VERSION_ID > 80599) { + return; + } + + if ( + version_compare( PHP_VERSION, '8.4', '>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80401, 80599>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'gt' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80401, 80599>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '>=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80400, 80599>', $x); + } + if ( + version_compare( PHP_VERSION, '8.4', 'ge' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80400, 80599>', $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 +{ + // add a upper bound, so we don't need to adjust + // the test when PHPStan adds support for PHP8.6+ + if (PHP_VERSION_ID > 80599) { + return; + } + + if ( + version_compare( PHP_VERSION, '8.4', '!=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>|int<80401, 80599>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', '<>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>|int<80401, 80599>', $x); + } + + if ( + version_compare( PHP_VERSION, '8.4', 'ne' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80399>|int<80401, 80599>', $x); + } +} + +function inverseOperandLower(): void +{ + // add a upper bound, so we don't need to adjust + // the test when PHPStan adds support for PHP8.6+ + if (PHP_VERSION_ID > 80599) { + return; + } + + if ( + version_compare( '8.3.12', PHP_VERSION, '<' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80313, 80599>', $x); + } + + if ( + version_compare( '8.3.12', PHP_VERSION, '>=' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80312>', $x); + } + + if ( + version_compare( '8.3.12', PHP_VERSION, '>' ) + ) { + $x = PHP_VERSION_ID; + assertType('int<80000, 80311>', $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 00000000000..37cc7cdf9d6 --- /dev/null +++ b/src/Php/SimplePhpVersionParser.php @@ -0,0 +1,26 @@ + '>', + 'lt' => 'gt', + '<=' => '>=', + 'le' => 'ge', + '>' => '<', + 'gt' => 'lt', + '>=' => '<=', + 'ge' => 'le', + ]; + + private TypeSpecifier $typeSpecifier; + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->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, false); + + 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, true); + 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, bool $phpVersionIsSecondOperand): ?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 ($phpVersionIsSecondOperand) { + // version_compare('8.3', PHP_VERSION, '<') means PHP_VERSION > '8.3' + $operatorString = self::INVERSE_OPERATORS[$operatorString] ?? $operatorString; + } + + 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 c0eeed33704..740abfb9157 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 {