diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 972c5cd7d3b..9058d29c0cf 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -72,6 +72,7 @@ use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\Php\VersionCompareHelper; use PHPStan\Type\ResourceType; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; use PHPStan\Type\StaticType; @@ -81,6 +82,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_keys; @@ -89,6 +91,7 @@ use function array_merge; use function array_reverse; use function array_shift; +use function array_values; use function count; use function in_array; use function is_string; @@ -543,6 +546,17 @@ public function specifyTypesInCondition( } } + // version_compare(PHP_VERSION, 'x.y') [<|<=] N or N [<|<=] version_compare(PHP_VERSION, 'x.y') + $versionCompareResult = $this->specifyTypesForVersionCompare2Arg($expr->left, $expr->right, $orEqual, $scope, $context, $expr); + if ($versionCompareResult !== null) { + $result = $result->unionWith($versionCompareResult); + } else { + $versionCompareResult = $this->specifyTypesForVersionCompare2Arg($expr->right, $expr->left, $orEqual, $scope, $context, $expr, false); + if ($versionCompareResult !== null) { + $result = $result->unionWith($versionCompareResult); + } + } + return $result; } elseif ($expr instanceof Node\Expr\BinaryOp\Greater) { @@ -3091,6 +3105,29 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope )->setRootExpr($expr); } + // version_compare(PHP_VERSION, 'x.y') === N + if ( + !$context->null() + && $unwrappedLeftExpr instanceof FuncCall + && !$unwrappedLeftExpr->isFirstClassCallable() + && count($unwrappedLeftExpr->getArgs()) === 2 + && $rightType instanceof ConstantIntegerType + && in_array($rightType->getValue(), [-1, 0, 1], true) + ) { + $parsed = VersionCompareHelper::parseVersionCompareFuncCall($unwrappedLeftExpr, $scope); + if ($parsed !== null) { + [$phpVersionArgIndex, $versionId] = $parsed; + $syntheticExpr = VersionCompareHelper::resultSetToPhpVersionIdComparison( + [$rightType->getValue()], + $phpVersionArgIndex, + $versionId, + ); + if ($syntheticExpr !== null) { + return $this->specifyTypesInCondition($scope, $syntheticExpr, $context)->setRootExpr($expr); + } + } + } + // get_class($a) === 'Foo' if ( $context->true() @@ -3383,4 +3420,54 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope return (new SpecifiedTypes([], []))->setRootExpr($expr); } + private function specifyTypesForVersionCompare2Arg( + Expr $funcCallSide, + Expr $constantSide, + bool $orEqual, + Scope $scope, + TypeSpecifierContext $context, + Expr $rootExpr, + bool $funcCallOnLeft = true, + ): ?SpecifiedTypes + { + if (!$funcCallSide instanceof FuncCall || $funcCallSide->isFirstClassCallable() || count($funcCallSide->getArgs()) !== 2) { + return null; + } + + $constantType = $scope->getType($constantSide); + if (!$constantType instanceof ConstantIntegerType) { + return null; + } + + $parsed = VersionCompareHelper::parseVersionCompareFuncCall($funcCallSide, $scope); + if ($parsed === null) { + return null; + } + + [$phpVersionArgIndex, $versionId] = $parsed; + + $n = $constantType->getValue(); + $possibleResults = [-1, 0, 1]; + + if ($funcCallOnLeft) { + // funcCall [<|<=] N + $resultSet = array_values(array_filter($possibleResults, static fn (int $r): bool => $orEqual ? $r <= $n : $r < $n)); + } else { + // N [<|<=] funcCall + $resultSet = array_values(array_filter($possibleResults, static fn (int $r): bool => $orEqual ? $n <= $r : $n < $r)); + } + + $syntheticExpr = VersionCompareHelper::resultSetToPhpVersionIdComparison( + $resultSet, + $phpVersionArgIndex, + $versionId, + ); + + if ($syntheticExpr === null) { + return null; + } + + return $this->specifyTypesInCondition($scope, $syntheticExpr, $context)->setRootExpr($rootExpr); + } + } diff --git a/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php b/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php index 6807d9ccaa8..8a7e01ddd48 100644 --- a/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php +++ b/src/Parser/RemoveUnusedCodeByPhpVersionIdVisitor.php @@ -6,7 +6,10 @@ use PhpParser\Node; use PhpParser\NodeVisitorAbstract; use PHPStan\Php\PhpVersion; +use PHPStan\Type\Php\VersionCompareHelper; use function count; +use function in_array; +use function strtolower; use function version_compare; final class RemoveUnusedCodeByPhpVersionIdVisitor extends NodeVisitorAbstract @@ -32,6 +35,31 @@ public function enterNode(Node $node): ?Node } $cond = $node->cond; + + $result = $this->evaluateVersionCompareCall($cond); + if ($result === null) { + $result = $this->evaluatePhpVersionIdComparison($cond); + } + if ($result === null) { + return null; + } + if ($result) { + // remove else + $node->cond = new Node\Expr\ConstFetch(new Node\Name('true')); + $node->else = null; + + return $node; + } + + // remove if + $node->cond = new Node\Expr\ConstFetch(new Node\Name('false')); + $node->stmts = []; + + return $node; + } + + private function evaluatePhpVersionIdComparison(Node\Expr $cond): ?bool + { if ( !$cond instanceof Node\Expr\BinaryOp\Smaller && !$cond instanceof Node\Expr\BinaryOp\SmallerOrEqual @@ -57,20 +85,65 @@ public function enterNode(Node $node): ?Node return null; } - $result = version_compare($operands[0], $operands[1], $operator); - if ($result) { - // remove else - $node->cond = new Node\Expr\ConstFetch(new Node\Name('true')); - $node->else = null; + return version_compare($operands[0], $operands[1], $operator); + } - return $node; + private function evaluateVersionCompareCall(Node\Expr $cond): ?bool + { + if (!$cond instanceof Node\Expr\FuncCall) { + return null; } - // remove if - $node->cond = new Node\Expr\ConstFetch(new Node\Name('false')); - $node->stmts = []; + if (!$cond->name instanceof Node\Name) { + return null; + } - return $node; + if (strtolower((string) $cond->name) !== 'version_compare') { + return null; + } + + $args = $cond->getArgs(); + if (count($args) !== 3) { + return null; + } + + $phpVersionArgIndex = null; + if ( + $args[0]->value instanceof Node\Expr\ConstFetch + && $args[0]->value->name->toString() === 'PHP_VERSION' + ) { + $phpVersionArgIndex = 0; + } elseif ( + $args[1]->value instanceof Node\Expr\ConstFetch + && $args[1]->value->name->toString() === 'PHP_VERSION' + ) { + $phpVersionArgIndex = 1; + } + + if ($phpVersionArgIndex === null) { + return null; + } + + $otherArgIndex = $phpVersionArgIndex === 0 ? 1 : 0; + if (!$args[$otherArgIndex]->value instanceof Node\Scalar\String_) { + return null; + } + $versionString = $args[$otherArgIndex]->value->value; + + if (!$args[2]->value instanceof Node\Scalar\String_) { + return null; + } + $operator = $args[2]->value->value; + + if (!in_array($operator, VersionCompareHelper::VALID_OPERATORS, true)) { + return null; + } + + if ($phpVersionArgIndex === 0) { + return version_compare($this->phpVersionString, $versionString, $operator); + } + + return version_compare($versionString, $this->phpVersionString, $operator); } /** diff --git a/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php index 9d854027f04..1aa26995a73 100644 --- a/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/VersionCompareFunctionDynamicReturnTypeExtension.php @@ -30,23 +30,6 @@ final class VersionCompareFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public const VALID_OPERATORS = [ - '<', - 'lt', - '<=', - 'le', - '>', - 'gt', - '>=', - 'ge', - '==', - '=', - 'eq', - '!=', - '<>', - 'ne', - ]; - /** * @param int|array{min: int, max: int}|null $configPhpVersion */ @@ -111,7 +94,7 @@ public function getTypeFromFunctionCall( if (isset($operatorStrings)) { foreach ($operatorStrings as $operatorString) { $operatorValue = $operatorString->getValue(); - if (!in_array($operatorValue, self::VALID_OPERATORS, true)) { + if (!in_array($operatorValue, VersionCompareHelper::VALID_OPERATORS, true)) { if (!$this->phpVersion->throwsValueErrorForInternalFunctions()) { $canBeNull = true; } diff --git a/src/Type/Php/VersionCompareFunctionDynamicThrowTypeExtension.php b/src/Type/Php/VersionCompareFunctionDynamicThrowTypeExtension.php index e4c4f92b34d..583dbe0295a 100644 --- a/src/Type/Php/VersionCompareFunctionDynamicThrowTypeExtension.php +++ b/src/Type/Php/VersionCompareFunctionDynamicThrowTypeExtension.php @@ -49,7 +49,7 @@ public function getThrowTypeFromFunctionCall( foreach ($operatorStrings as $operatorString) { $operatorValue = $operatorString->getValue(); - if (!in_array($operatorValue, VersionCompareFunctionDynamicReturnTypeExtension::VALID_OPERATORS, true)) { + if (!in_array($operatorValue, VersionCompareHelper::VALID_OPERATORS, true)) { return $functionReflection->getThrowType(); } } diff --git a/src/Type/Php/VersionCompareFunctionTypeSpecifyingExtension.php b/src/Type/Php/VersionCompareFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000000..bc599544f1a --- /dev/null +++ b/src/Type/Php/VersionCompareFunctionTypeSpecifyingExtension.php @@ -0,0 +1,78 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported( + FunctionReflection $functionReflection, + FuncCall $node, + TypeSpecifierContext $context, + ): bool + { + return !$context->null() + && $functionReflection->getName() === 'version_compare' + && count($node->getArgs()) === 3; + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $parsed = VersionCompareHelper::parseVersionCompareFuncCall($node, $scope); + if ($parsed === null) { + return new SpecifiedTypes([], []); + } + + [$phpVersionArgIndex, $versionId] = $parsed; + + $args = $node->getArgs(); + $operatorStrings = $scope->getType($args[2]->value)->getConstantStrings(); + if (count($operatorStrings) !== 1) { + return new SpecifiedTypes([], []); + } + + $comparisonClass = VersionCompareHelper::operatorToComparisonClass($operatorStrings[0]->getValue()); + if ($comparisonClass === null) { + return new SpecifiedTypes([], []); + } + + $phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID')); + $versionIdExpr = new Int_($versionId); + + if ($phpVersionArgIndex === 0) { + $syntheticExpr = new $comparisonClass($phpVersionIdExpr, $versionIdExpr); + } else { + $syntheticExpr = new $comparisonClass($versionIdExpr, $phpVersionIdExpr); + } + + return $this->typeSpecifier->specifyTypesInCondition($scope, $syntheticExpr, $context); + } + +} diff --git a/src/Type/Php/VersionCompareHelper.php b/src/Type/Php/VersionCompareHelper.php new file mode 100644 index 00000000000..3e616c82e18 --- /dev/null +++ b/src/Type/Php/VersionCompareHelper.php @@ -0,0 +1,202 @@ +', + 'gt', + '>=', + 'ge', + '==', + '=', + 'eq', + '!=', + '<>', + 'ne', + ]; + + /** + * Parses a version_compare() function call that involves PHP_VERSION. + * + * @return array{int, int}|null [phpVersionArgIndex, versionId] or null if not applicable + */ + public static function parseVersionCompareFuncCall(FuncCall $funcCall, Scope $scope): ?array + { + if (!$funcCall->name instanceof Name) { + return null; + } + + if (strtolower((string) $funcCall->name) !== 'version_compare') { + return null; + } + + if ($funcCall->isFirstClassCallable()) { + return null; + } + + $args = $funcCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $phpVersionArgIndex = self::getPhpVersionArgIndex($args[0]->value, $args[1]->value); + if ($phpVersionArgIndex === null) { + return null; + } + + $otherArgIndex = $phpVersionArgIndex === 0 ? 1 : 0; + $versionId = self::resolveVersionId($args[$otherArgIndex]->value, $scope); + if ($versionId === null) { + return null; + } + + return [$phpVersionArgIndex, $versionId]; + } + + private static function getPhpVersionArgIndex(Expr $arg1, Expr $arg2): ?int + { + if (self::isPhpVersionConstant($arg1)) { + return 0; + } + + if (self::isPhpVersionConstant($arg2)) { + return 1; + } + + return null; + } + + private static function isPhpVersionConstant(Expr $expr): bool + { + return $expr instanceof ConstFetch + && $expr->name->toString() === 'PHP_VERSION'; + } + + private static function resolveVersionId(Expr $expr, Scope $scope): ?int + { + $constantStrings = $scope->getType($expr)->getConstantStrings(); + if (count($constantStrings) !== 1) { + return null; + } + + return self::versionStringToId($constantStrings[0]->getValue()); + } + + public static function versionStringToId(string $version): ?int + { + $parts = explode('.', $version); + if (count($parts) > 3) { + return null; + } + + foreach ($parts as $part) { + if (!is_numeric($part) || (int) $part < 0) { + return null; + } + } + + $major = (int) $parts[0]; + $minor = (int) ($parts[1] ?? 0); + $patch = (int) ($parts[2] ?? 0); + + return $major * 10000 + $minor * 100 + $patch; + } + + /** + * Given a 2-arg version_compare(PHP_VERSION, 'x.y') result compared + * against a constant integer, return the equivalent PHP_VERSION_ID comparison. + * + * @param int[] $resultSet Subset of [-1, 0, 1] that the comparison selects + */ + public static function resultSetToPhpVersionIdComparison( + array $resultSet, + int $phpVersionArgIndex, + int $versionId, + ): ?BinaryOp + { + sort($resultSet); + + $phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID')); + $versionIdExpr = new Int_($versionId); + + if ($phpVersionArgIndex === 0) { + $leftExpr = $phpVersionIdExpr; + $rightExpr = $versionIdExpr; + } else { + $leftExpr = $versionIdExpr; + $rightExpr = $phpVersionIdExpr; + } + + if ($resultSet === [-1]) { + return new BinaryOp\Smaller($leftExpr, $rightExpr); + } + if ($resultSet === [-1, 0]) { + return new BinaryOp\SmallerOrEqual($leftExpr, $rightExpr); + } + if ($resultSet === [0]) { + return new BinaryOp\Equal($leftExpr, $rightExpr); + } + if ($resultSet === [0, 1]) { + return new BinaryOp\GreaterOrEqual($leftExpr, $rightExpr); + } + if ($resultSet === [1]) { + return new BinaryOp\Greater($leftExpr, $rightExpr); + } + if ($resultSet === [-1, 1]) { + return new BinaryOp\NotEqual($leftExpr, $rightExpr); + } + + return null; + } + + /** + * Maps version_compare operator strings to binary operator class names. + * + * @return class-string|null + */ + public static function operatorToComparisonClass(string $operator): ?string + { + if (in_array($operator, ['<', 'lt'], true)) { + return Expr\BinaryOp\Smaller::class; + } + if (in_array($operator, ['<=', 'le'], true)) { + return Expr\BinaryOp\SmallerOrEqual::class; + } + if (in_array($operator, ['>', 'gt'], true)) { + return Expr\BinaryOp\Greater::class; + } + if (in_array($operator, ['>=', 'ge'], true)) { + return Expr\BinaryOp\GreaterOrEqual::class; + } + if (in_array($operator, ['==', '=', 'eq'], true)) { + return Expr\BinaryOp\Equal::class; + } + if (in_array($operator, ['!=', '<>', 'ne'], true)) { + return Expr\BinaryOp\NotEqual::class; + } + + return null; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/version-compare-php-version-scope.php b/tests/PHPStan/Analyser/nsrt/version-compare-php-version-scope.php new file mode 100644 index 00000000000..547308301b1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/version-compare-php-version-scope.php @@ -0,0 +1,152 @@ +=') +if (version_compare(PHP_VERSION, '8.0', '>=')) { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} + +// 3-arg form: version_compare(PHP_VERSION, '8.0', '<') +if (version_compare(PHP_VERSION, '8.0', '<')) { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} else { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} + +// 3-arg form: version_compare(PHP_VERSION, '8.4', '>')) +if (version_compare(PHP_VERSION, '8.4', '>')) { + assertType('int<80401, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 80400>', PHP_VERSION_ID); +} + +// 3-arg form: version_compare(PHP_VERSION, '8.4', '<=')) +if (version_compare(PHP_VERSION, '8.4', '<=')) { + assertType('int<50207, 80400>', PHP_VERSION_ID); +} else { + assertType('int<80401, 80599>', PHP_VERSION_ID); +} + +// 3-arg form: swapped arguments - version_compare('8.0', PHP_VERSION, '<=') +if (version_compare('8.0', PHP_VERSION, '<=')) { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} + +// 3-arg form: with patch version - version_compare(PHP_VERSION, '8.0.1', '>=') +if (version_compare(PHP_VERSION, '8.0.1', '>=')) { + assertType('int<80001, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 80000>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') === 1 +if (version_compare(PHP_VERSION, '8.0') === 1) { + assertType('int<80001, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 80000>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') >= 0 +if (version_compare(PHP_VERSION, '8.0') >= 0) { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') === -1 +if (version_compare(PHP_VERSION, '8.0') === -1) { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} else { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') < 0 +if (version_compare(PHP_VERSION, '8.0') < 0) { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} else { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} + +// 3-arg form with operator alias: version_compare(PHP_VERSION, '8.0', 'ge') +if (version_compare(PHP_VERSION, '8.0', 'ge')) { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} + +// 3-arg form with operator alias: version_compare(PHP_VERSION, '8.0', 'lt') +if (version_compare(PHP_VERSION, '8.0', 'lt')) { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} else { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} + +// 3-arg form: equality - version_compare(PHP_VERSION, '8.0', '==') +if (version_compare(PHP_VERSION, '8.0', '==')) { + assertType('80000', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>|int<80001, 80599>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') === 0 +if (version_compare(PHP_VERSION, '8.0') === 0) { + assertType('80000', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>|int<80001, 80599>', PHP_VERSION_ID); +} + +// 2-arg form with swapped args: version_compare('8.0', PHP_VERSION) === -1 +if (version_compare('8.0', PHP_VERSION) === -1) { + assertType('int<80001, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 80000>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') > 0 +if (version_compare(PHP_VERSION, '8.0') > 0) { + assertType('int<80001, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 80000>', PHP_VERSION_ID); +} + +// 2-arg form: version_compare(PHP_VERSION, '8.0') <= 0 +if (version_compare(PHP_VERSION, '8.0') <= 0) { + assertType('int<50207, 80000>', PHP_VERSION_ID); +} else { + assertType('int<80001, 80599>', PHP_VERSION_ID); +} + +// 3-arg form with major-only version: version_compare(PHP_VERSION, '8', '>=') +if (version_compare(PHP_VERSION, '8', '>=')) { + assertType('int<80000, 80599>', PHP_VERSION_ID); +} else { + assertType('int<50207, 79999>', PHP_VERSION_ID); +} + +// Playground sample: variadic parameter type depends on PHP version +if (version_compare(PHP_VERSION, '8.0', '>=')) { + class FooVersionCompare8 { + /** + * @param mixed $x + */ + public function doBaz(...$x): void { + assertType('array', $x); + } + } +} else { + class FooVersionCompare9 { + /** + * @param mixed $x + */ + public function doBaz(...$x): void { + assertType('list', $x); + } + } +} diff --git a/tests/PHPStan/Parser/CleaningParserTest.php b/tests/PHPStan/Parser/CleaningParserTest.php index 5a8bb215af4..3dfd53a2131 100644 --- a/tests/PHPStan/Parser/CleaningParserTest.php +++ b/tests/PHPStan/Parser/CleaningParserTest.php @@ -58,6 +58,21 @@ public static function dataParse(): iterable __DIR__ . '/data/cleaning-property-hooks-after.php', 80400, ], + [ + __DIR__ . '/data/cleaning-version-compare-before.php', + __DIR__ . '/data/cleaning-version-compare-after-81.php', + 80100, + ], + [ + __DIR__ . '/data/cleaning-version-compare-before.php', + __DIR__ . '/data/cleaning-version-compare-after-81.php', + 80200, + ], + [ + __DIR__ . '/data/cleaning-version-compare-before.php', + __DIR__ . '/data/cleaning-version-compare-after-74.php', + 70400, + ], ]; } diff --git a/tests/PHPStan/Parser/data/cleaning-version-compare-after-74.php b/tests/PHPStan/Parser/data/cleaning-version-compare-after-74.php new file mode 100644 index 00000000000..f4ab4f5bb14 --- /dev/null +++ b/tests/PHPStan/Parser/data/cleaning-version-compare-after-74.php @@ -0,0 +1,9 @@ +=')) { + doFoo1(); + doFoo2(); +} else { + doBar1(); + doBar2(); +}