Skip to content

Commit 62ed200

Browse files
authored
Add a dynamic return type extension for clamp() (#6684)
1 parent cdbfc45 commit 62ed200

2 files changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\BinaryOp\Smaller;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PhpParser\Node\Expr\Ternary;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\DependencyInjection\AutowiredService;
10+
use PHPStan\Node\Expr\AlwaysRememberedExpr;
11+
use PHPStan\Reflection\FunctionReflection;
12+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
13+
use PHPStan\Type\Type;
14+
use function count;
15+
16+
#[AutowiredService]
17+
final class ClampFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
18+
{
19+
20+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
21+
{
22+
return $functionReflection->getName() === 'clamp';
23+
}
24+
25+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
26+
{
27+
$args = $functionCall->getArgs();
28+
if (count($args) !== 3) {
29+
return null;
30+
}
31+
32+
foreach ($args as $arg) {
33+
if ($arg->unpack) {
34+
return null;
35+
}
36+
}
37+
38+
$valueExpr = $args[0]->value;
39+
$minExpr = $args[1]->value;
40+
$maxExpr = $args[2]->value;
41+
$valueType = $scope->getType($valueExpr);
42+
$minType = $scope->getType($minExpr);
43+
$maxType = $scope->getType($maxExpr);
44+
45+
$value = new AlwaysRememberedExpr($valueExpr, $valueType, $scope->getNativeType($valueExpr));
46+
$min = new AlwaysRememberedExpr($minExpr, $minType, $scope->getNativeType($minExpr));
47+
$max = new AlwaysRememberedExpr($maxExpr, $maxType, $scope->getNativeType($maxExpr));
48+
49+
// same comparisons as php_math_clamp(): $max < $value ? $max : ($value < $min ? $min : $value)
50+
return $scope->getType(new Ternary(
51+
new Smaller($max, $value),
52+
$max,
53+
new Ternary(
54+
new Smaller($value, $min),
55+
$min,
56+
$value,
57+
),
58+
));
59+
}
60+
61+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php // lint >= 8.6
2+
3+
declare(strict_types = 1);
4+
5+
namespace Clamp;
6+
7+
use DateTime;
8+
use DateTimeImmutable;
9+
use function PHPStan\Testing\assertType;
10+
11+
/** @phpstan-impure */
12+
function impureInt(): int
13+
{
14+
return rand(0, 100) - 50;
15+
}
16+
17+
/**
18+
* @param int<0, 100> $percent
19+
* @param int<-50, 50> $delta
20+
* @param list<int> $list
21+
*/
22+
function doFoo(int $i, float $f, int|float $n, int $a, int $b, string $s, ?int $maybe, int $percent, int $delta, array $list): void
23+
{
24+
assertType('int<1, 10>', clamp($i, 1, 10));
25+
assertType('float', clamp($f, 1.0, 10.0));
26+
assertType('1.0|10.0|int<1, 10>', clamp($i, 1.0, 10.0));
27+
assertType('float|int<0, 1>', clamp($n, 0, 1));
28+
assertType('int', clamp($i, $a, $b));
29+
assertType('int<0, 100>', clamp($percent + $delta, 0, 100));
30+
assertType('5', clamp(5, 1, 10));
31+
assertType('10', clamp(15, 1, 10));
32+
assertType('1', clamp(-3, 1, 10));
33+
assertType('string', clamp($s, 'a', 'm'));
34+
assertType('int<1, 10>', clamp($maybe, 1, 10));
35+
assertType('int<1, 10>', clamp(max: 10, value: $i, min: 1));
36+
assertType('int<1, 10>', clamp(impureInt(), 1, 10));
37+
}
38+
39+
/**
40+
* @param 1|2|3 $lowUnion
41+
* @param 8|9|10 $highUnion
42+
* @param int<0, 5> $lowRange
43+
* @param int<8, 10> $highRange
44+
*/
45+
function doBounds(int $i, int $lowUnion, int $highUnion, int $lowRange, int $highRange): void
46+
{
47+
assertType('int<1, 10>', clamp($i, $lowUnion, $highUnion));
48+
assertType('5', clamp(5, $lowUnion, $highUnion));
49+
assertType('1|2|3', clamp(0, $lowUnion, $highUnion));
50+
assertType('int<0, 10>', clamp($i, $lowRange, $highRange));
51+
assertType('int<8, 10>', clamp(12, $lowRange, $highRange));
52+
assertType('int<0, 5>', clamp(-1, $lowRange, $highRange));
53+
}
54+
55+
/**
56+
* @param int|array<int> $intOrArray
57+
* @param list<int> $list
58+
*/
59+
function doArrays(int|array $intOrArray, array $list): void
60+
{
61+
assertType('array<int>|int<1, 10>', clamp($intOrArray, 1, 10));
62+
assertType('list<int>', clamp($list, [0], [10]));
63+
assertType('array{10}|array{1}|array{5}', clamp([5], [1], [10]));
64+
assertType('1|10|array{5}', clamp([5], 1, 10));
65+
}
66+
67+
function doDates(DateTimeImmutable $date, DateTimeImmutable $min, DateTimeImmutable $max, DateTime $mutable): void
68+
{
69+
assertType('DateTimeImmutable', clamp($date, $min, $max));
70+
assertType('DateTime|DateTimeImmutable', clamp($mutable, $min, $max));
71+
assertType('DateTimeImmutable', clamp(new DateTimeImmutable('2025-08-01'), new DateTimeImmutable('2025-08-15'), new DateTimeImmutable('2025-08-31')));
72+
}
73+
74+
/**
75+
* @param array{int, 1, 10} $tuple
76+
* @param array{1, 10} $bounds
77+
* @param list<int> $list
78+
*/
79+
function doSplat(int $i, array $tuple, array $bounds, array $list): void
80+
{
81+
assertType('mixed', clamp(...$tuple));
82+
assertType('mixed', clamp($i, ...$bounds));
83+
assertType('mixed', clamp(...$list));
84+
assertType('mixed', clamp(...[$i], ...[1], ...[10]));
85+
assertType('mixed', clamp($i, 1, ...[10]));
86+
}

0 commit comments

Comments
 (0)