From 883a5ad2dc3831128d9a22e2797f8d649cd94e16 Mon Sep 17 00:00:00 2001 From: Pascal CESCON - Amoifr Date: Mon, 5 Oct 2026 21:40:12 +0200 Subject: [PATCH] Add a dynamic return type extension for clamp() Closes phpstan/phpstan#15366 --- .../Php/ClampFunctionReturnTypeExtension.php | 66 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/clamp.php | 29 ++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/Type/Php/ClampFunctionReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/clamp.php diff --git a/src/Type/Php/ClampFunctionReturnTypeExtension.php b/src/Type/Php/ClampFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..4ac17205dbe --- /dev/null +++ b/src/Type/Php/ClampFunctionReturnTypeExtension.php @@ -0,0 +1,66 @@ +getName() === 'clamp'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) !== 3) { + return null; + } + + foreach ($args as $arg) { + if ($arg->unpack) { + return null; + } + } + + $valueExpr = $args[0]->value; + $minExpr = $args[1]->value; + $maxExpr = $args[2]->value; + $valueType = $scope->getType($valueExpr); + $minType = $scope->getType($minExpr); + $maxType = $scope->getType($maxExpr); + + // arrays are compared by size first, keep the native return type for them + if (!$valueType->isArray()->no() || !$minType->isArray()->no() || !$maxType->isArray()->no()) { + return null; + } + + $value = new AlwaysRememberedExpr($valueExpr, $valueType, $scope->getNativeType($valueExpr)); + $min = new AlwaysRememberedExpr($minExpr, $minType, $scope->getNativeType($minExpr)); + $max = new AlwaysRememberedExpr($maxExpr, $maxType, $scope->getNativeType($maxExpr)); + + // same comparisons as php_math_clamp(): $max < $value ? $max : ($value < $min ? $min : $value) + return $scope->getType(new Ternary( + new Smaller($max, $value), + $max, + new Ternary( + new Smaller($value, $min), + $min, + $value, + ), + )); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/clamp.php b/tests/PHPStan/Analyser/nsrt/clamp.php new file mode 100644 index 00000000000..3af10659180 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/clamp.php @@ -0,0 +1,29 @@ += 8.6 + +declare(strict_types = 1); + +namespace Clamp; + +use function PHPStan\Testing\assertType; + +/** + * @param int<0, 100> $percent + * @param int<-50, 50> $delta + * @param list $list + */ +function doFoo(int $i, float $f, int|float $n, int $a, int $b, string $s, ?int $maybe, int $percent, int $delta, array $list): void +{ + assertType('int<1, 10>', clamp($i, 1, 10)); + assertType('float', clamp($f, 1.0, 10.0)); + assertType('1.0|10.0|int<1, 10>', clamp($i, 1.0, 10.0)); + assertType('float|int<0, 1>', clamp($n, 0, 1)); + assertType('int', clamp($i, $a, $b)); + assertType('int<0, 100>', clamp($percent + $delta, 0, 100)); + assertType('5', clamp(5, 1, 10)); + assertType('10', clamp(15, 1, 10)); + assertType('1', clamp(-3, 1, 10)); + assertType('string', clamp($s, 'a', 'm')); + assertType('int<1, 10>', clamp($maybe, 1, 10)); + assertType('int<1, 10>', clamp(max: 10, value: $i, min: 1)); + assertType('mixed', clamp($list, [0], [10])); +}