|
70 | 70 | use PHPStan\Type\NullType; |
71 | 71 | use PHPStan\Type\ObjectType; |
72 | 72 | use PHPStan\Type\ObjectWithoutClassType; |
| 73 | +use PHPStan\Type\Php\VersionCompareFunctionTypeSpecifyingExtension; |
73 | 74 | use PHPStan\Type\ResourceType; |
74 | 75 | use PHPStan\Type\StaticMethodTypeSpecifyingExtension; |
75 | 76 | use PHPStan\Type\StaticType; |
|
87 | 88 | use function array_shift; |
88 | 89 | use function count; |
89 | 90 | use function in_array; |
| 91 | +use function is_int; |
90 | 92 | use function is_string; |
91 | 93 | use function strtolower; |
92 | 94 | use function substr; |
@@ -261,6 +263,34 @@ public function specifyTypesInCondition( |
261 | 263 | )->setRootExpr($expr); |
262 | 264 | } |
263 | 265 |
|
| 266 | + // version_compare(PHP_VERSION, '8.0') < 0 or 0 < version_compare(PHP_VERSION, '8.0') |
| 267 | + if (!$context->null()) { |
| 268 | + $orEqual = $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual; |
| 269 | + |
| 270 | + if ( |
| 271 | + $expr->left instanceof FuncCall |
| 272 | + && $expr->left->name instanceof Name |
| 273 | + && $expr->left->name->toLowerString() === 'version_compare' |
| 274 | + && count($expr->left->getArgs()) === 2 |
| 275 | + ) { |
| 276 | + $result = $this->specifyTypesForVersionCompareSmallerResult($expr->left, $scope, $scope->getType($expr->right), $orEqual, false, $context, $expr); |
| 277 | + if ($result !== null) { |
| 278 | + return $result; |
| 279 | + } |
| 280 | + } elseif ( |
| 281 | + $expr->right instanceof FuncCall |
| 282 | + && $expr->right->name instanceof Name |
| 283 | + && $expr->right->name->toLowerString() === 'version_compare' |
| 284 | + && count($expr->right->getArgs()) === 2 |
| 285 | + ) { |
| 286 | + // value < version_compare(...) → version_compare(...) > value |
| 287 | + $result = $this->specifyTypesForVersionCompareSmallerResult($expr->right, $scope, $scope->getType($expr->left), $orEqual, true, $context, $expr); |
| 288 | + if ($result !== null) { |
| 289 | + return $result; |
| 290 | + } |
| 291 | + } |
| 292 | + } |
| 293 | + |
264 | 294 | $orEqual = $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual; |
265 | 295 | $offset = $orEqual ? 0 : 1; |
266 | 296 | $leftType = $scope->getType($expr->left); |
@@ -2486,6 +2516,21 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope |
2486 | 2516 | )->setRootExpr($expr); |
2487 | 2517 | } |
2488 | 2518 |
|
| 2519 | + // version_compare(PHP_VERSION, '8.0') === 1 |
| 2520 | + if ( |
| 2521 | + !$context->null() |
| 2522 | + && $unwrappedLeftExpr instanceof FuncCall |
| 2523 | + && $unwrappedLeftExpr->name instanceof Name |
| 2524 | + && $unwrappedLeftExpr->name->toLowerString() === 'version_compare' |
| 2525 | + && count($unwrappedLeftExpr->getArgs()) === 2 |
| 2526 | + && $rightType->isInteger()->yes() |
| 2527 | + ) { |
| 2528 | + $result = $this->specifyTypesForVersionCompareResult($unwrappedLeftExpr, $scope, $rightType, $context, $expr); |
| 2529 | + if ($result !== null) { |
| 2530 | + return $result; |
| 2531 | + } |
| 2532 | + } |
| 2533 | + |
2489 | 2534 | // get_class($a) === 'Foo' |
2490 | 2535 | if ( |
2491 | 2536 | $context->true() |
@@ -2776,4 +2821,219 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope |
2776 | 2821 | return (new SpecifiedTypes([], []))->setRootExpr($expr); |
2777 | 2822 | } |
2778 | 2823 |
|
| 2824 | + /** |
| 2825 | + * Parses a version_compare FuncCall to extract the PHP version argument index and the version ID. |
| 2826 | + * |
| 2827 | + * @return array{int, int}|null [phpVersionArgIndex, versionId] or null if not applicable |
| 2828 | + */ |
| 2829 | + private function parseVersionCompareFuncCall(FuncCall $funcCall, Scope $scope): ?array |
| 2830 | + { |
| 2831 | + $args = $funcCall->getArgs(); |
| 2832 | + if (count($args) !== 2) { |
| 2833 | + return null; |
| 2834 | + } |
| 2835 | + |
| 2836 | + $phpVersionArgIndex = null; |
| 2837 | + if ($args[0]->value instanceof ConstFetch && $args[0]->value->name->toString() === 'PHP_VERSION') { |
| 2838 | + $phpVersionArgIndex = 0; |
| 2839 | + } elseif ($args[1]->value instanceof ConstFetch && $args[1]->value->name->toString() === 'PHP_VERSION') { |
| 2840 | + $phpVersionArgIndex = 1; |
| 2841 | + } |
| 2842 | + |
| 2843 | + if ($phpVersionArgIndex === null) { |
| 2844 | + return null; |
| 2845 | + } |
| 2846 | + |
| 2847 | + $otherArgIndex = $phpVersionArgIndex === 0 ? 1 : 0; |
| 2848 | + $versionStrings = $scope->getType($args[$otherArgIndex]->value)->getConstantStrings(); |
| 2849 | + if (count($versionStrings) !== 1) { |
| 2850 | + return null; |
| 2851 | + } |
| 2852 | + |
| 2853 | + $versionId = VersionCompareFunctionTypeSpecifyingExtension::parseVersionStringToId($versionStrings[0]->getValue()); |
| 2854 | + if ($versionId === null) { |
| 2855 | + return null; |
| 2856 | + } |
| 2857 | + |
| 2858 | + return [$phpVersionArgIndex, $versionId]; |
| 2859 | + } |
| 2860 | + |
| 2861 | + /** |
| 2862 | + * Handles version_compare(PHP_VERSION, 'ver') === value in resolveNormalizedIdentical. |
| 2863 | + */ |
| 2864 | + private function specifyTypesForVersionCompareResult( |
| 2865 | + FuncCall $funcCall, |
| 2866 | + Scope $scope, |
| 2867 | + Type $comparisonValue, |
| 2868 | + TypeSpecifierContext $context, |
| 2869 | + Expr $rootExpr, |
| 2870 | + ): ?SpecifiedTypes |
| 2871 | + { |
| 2872 | + $parsed = $this->parseVersionCompareFuncCall($funcCall, $scope); |
| 2873 | + if ($parsed === null) { |
| 2874 | + return null; |
| 2875 | + } |
| 2876 | + |
| 2877 | + [$phpVersionArgIndex, $versionId] = $parsed; |
| 2878 | + $phpVersionSwapped = $phpVersionArgIndex === 1; |
| 2879 | + |
| 2880 | + $constantValues = $comparisonValue->getConstantScalarValues(); |
| 2881 | + if (count($constantValues) !== 1 || !is_int($constantValues[0])) { |
| 2882 | + return null; |
| 2883 | + } |
| 2884 | + |
| 2885 | + $value = $constantValues[0]; |
| 2886 | + if (!in_array($value, [-1, 0, 1], true)) { |
| 2887 | + return null; |
| 2888 | + } |
| 2889 | + |
| 2890 | + // For true/truthy context (===), the result set is the matched value |
| 2891 | + // For false/falsey context (!==), the result set is the complement |
| 2892 | + if ($context->true() || $context->truthy()) { |
| 2893 | + $resultSet = [$value]; |
| 2894 | + } else { |
| 2895 | + $all = [-1, 0, 1]; |
| 2896 | + $resultSet = []; |
| 2897 | + foreach ($all as $r) { |
| 2898 | + if ($r === $value) { |
| 2899 | + continue; |
| 2900 | + } |
| 2901 | + |
| 2902 | + $resultSet[] = $r; |
| 2903 | + } |
| 2904 | + } |
| 2905 | + |
| 2906 | + $phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID')); |
| 2907 | + $versionIdType = new ConstantIntegerType($versionId); |
| 2908 | + |
| 2909 | + return $this->specifyTypesFromVersionCompareResultSet($resultSet, $phpVersionIdExpr, $versionIdType, $phpVersionSwapped, $scope, $rootExpr); |
| 2910 | + } |
| 2911 | + |
| 2912 | + /** |
| 2913 | + * Handles version_compare(PHP_VERSION, 'ver') < value or value < version_compare(PHP_VERSION, 'ver') |
| 2914 | + * in the Smaller/SmallerOrEqual section. |
| 2915 | + */ |
| 2916 | + private function specifyTypesForVersionCompareSmallerResult( |
| 2917 | + FuncCall $funcCall, |
| 2918 | + Scope $scope, |
| 2919 | + Type $comparisonValue, |
| 2920 | + bool $orEqual, |
| 2921 | + bool $exprSwapped, |
| 2922 | + TypeSpecifierContext $context, |
| 2923 | + Expr $rootExpr, |
| 2924 | + ): ?SpecifiedTypes |
| 2925 | + { |
| 2926 | + $parsed = $this->parseVersionCompareFuncCall($funcCall, $scope); |
| 2927 | + if ($parsed === null) { |
| 2928 | + return null; |
| 2929 | + } |
| 2930 | + |
| 2931 | + [$phpVersionArgIndex, $versionId] = $parsed; |
| 2932 | + $phpVersionSwapped = $phpVersionArgIndex === 1; |
| 2933 | + |
| 2934 | + $constantValues = $comparisonValue->getConstantScalarValues(); |
| 2935 | + if (count($constantValues) !== 1 || !is_int($constantValues[0])) { |
| 2936 | + return null; |
| 2937 | + } |
| 2938 | + |
| 2939 | + $value = $constantValues[0]; |
| 2940 | + |
| 2941 | + // Determine what comparison operation version_compare_result OP value implies |
| 2942 | + // First, compute the effective operator on version_compare_result |
| 2943 | + $op = $orEqual ? '<=' : '<'; |
| 2944 | + if ($exprSwapped) { |
| 2945 | + // value < version_compare(...) → version_compare(...) > value |
| 2946 | + // value <= version_compare(...) → version_compare(...) >= value |
| 2947 | + $op = $orEqual ? '>=' : '>'; |
| 2948 | + } |
| 2949 | + |
| 2950 | + // For false context, flip the operator (like the standard Smaller handler does) |
| 2951 | + if ($context->false()) { |
| 2952 | + $op = match ($op) { |
| 2953 | + '<' => '>=', |
| 2954 | + '<=' => '>', |
| 2955 | + '>' => '<=', |
| 2956 | + '>=' => '<', |
| 2957 | + }; |
| 2958 | + } |
| 2959 | + |
| 2960 | + // Determine which version_compare results satisfy: result OP value |
| 2961 | + $resultSet = match ($op) { |
| 2962 | + '<' => $value > -1 ? ($value > 0 ? ($value > 1 ? [] : [-1, 0]) : [-1]) : [], |
| 2963 | + '<=' => $value >= -1 ? ($value >= 0 ? ($value >= 1 ? [-1, 0, 1] : [-1, 0]) : [-1]) : [], |
| 2964 | + '>' => $value < 1 ? ($value < 0 ? ($value < -1 ? [] : [0, 1]) : [1]) : [], |
| 2965 | + default => $value <= 1 ? ($value <= 0 ? ($value <= -1 ? [-1, 0, 1] : [0, 1]) : [1]) : [], |
| 2966 | + }; |
| 2967 | + |
| 2968 | + if ($resultSet === [] || count($resultSet) === 3) { |
| 2969 | + return null; |
| 2970 | + } |
| 2971 | + |
| 2972 | + $phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID')); |
| 2973 | + $versionIdType = new ConstantIntegerType($versionId); |
| 2974 | + |
| 2975 | + // Always use truthy context since we've already computed the correct type for the branch |
| 2976 | + return $this->specifyTypesFromVersionCompareResultSet($resultSet, $phpVersionIdExpr, $versionIdType, $phpVersionSwapped, $scope, $rootExpr); |
| 2977 | + } |
| 2978 | + |
| 2979 | + /** |
| 2980 | + * Creates SpecifiedTypes for PHP_VERSION_ID based on a set of version_compare results. |
| 2981 | + * |
| 2982 | + * @param list<int> $resultSet Subset of [-1, 0, 1] |
| 2983 | + */ |
| 2984 | + private function specifyTypesFromVersionCompareResultSet( |
| 2985 | + array $resultSet, |
| 2986 | + Expr $phpVersionIdExpr, |
| 2987 | + ConstantIntegerType $versionIdType, |
| 2988 | + bool $phpVersionSwapped, |
| 2989 | + Scope $scope, |
| 2990 | + Expr $rootExpr, |
| 2991 | + ): ?SpecifiedTypes |
| 2992 | + { |
| 2993 | + // Map result set to PHP_VERSION_ID type, accounting for swapped argument order |
| 2994 | + if (count($resultSet) === 1) { |
| 2995 | + $r = $resultSet[0]; |
| 2996 | + $effectiveR = $phpVersionSwapped ? -$r : $r; |
| 2997 | + |
| 2998 | + $type = match ($effectiveR) { |
| 2999 | + 1 => $versionIdType->getGreaterType($this->phpVersion), |
| 3000 | + -1 => $versionIdType->getSmallerType($this->phpVersion), |
| 3001 | + 0 => $versionIdType, |
| 3002 | + default => null, |
| 3003 | + }; |
| 3004 | + |
| 3005 | + if ($type === null) { |
| 3006 | + return null; |
| 3007 | + } |
| 3008 | + |
| 3009 | + return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr); |
| 3010 | + } |
| 3011 | + |
| 3012 | + // Two results: [-1, 0] or [0, 1] or [-1, 1] |
| 3013 | + if ($resultSet === [-1, 0]) { |
| 3014 | + $effectiveOp = $phpVersionSwapped ? '>' : '<='; |
| 3015 | + } elseif ($resultSet === [0, 1]) { |
| 3016 | + $effectiveOp = $phpVersionSwapped ? '<' : '>='; |
| 3017 | + } elseif ($resultSet === [-1, 1]) { |
| 3018 | + // != case: PHP_VERSION_ID < versionId OR PHP_VERSION_ID > versionId |
| 3019 | + $type = TypeCombinator::union( |
| 3020 | + $versionIdType->getSmallerType($this->phpVersion), |
| 3021 | + $versionIdType->getGreaterType($this->phpVersion), |
| 3022 | + ); |
| 3023 | + |
| 3024 | + return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr); |
| 3025 | + } else { |
| 3026 | + return null; |
| 3027 | + } |
| 3028 | + |
| 3029 | + $type = match ($effectiveOp) { |
| 3030 | + '<' => $versionIdType->getSmallerType($this->phpVersion), |
| 3031 | + '<=' => $versionIdType->getSmallerOrEqualType($this->phpVersion), |
| 3032 | + '>' => $versionIdType->getGreaterType($this->phpVersion), |
| 3033 | + default => $versionIdType->getGreaterOrEqualType($this->phpVersion), |
| 3034 | + }; |
| 3035 | + |
| 3036 | + return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr); |
| 3037 | + } |
| 3038 | + |
2779 | 3039 | } |
0 commit comments