Skip to content

Commit 87fc37e

Browse files
authored
Handle dynamic return types for bc math functions
1 parent 34c4829 commit 87fc37e

File tree

4 files changed

+289
-0
lines changed

4 files changed

+289
-0
lines changed

conf/config.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,11 @@ services:
801801
tags:
802802
- phpstan.broker.dynamicFunctionReturnTypeExtension
803803

804+
-
805+
class: PHPStan\Type\Php\BcMathStringOrNullReturnTypeExtension
806+
tags:
807+
- phpstan.broker.dynamicFunctionReturnTypeExtension
808+
804809
-
805810
class: PHPStan\Type\Php\ClosureBindDynamicReturnTypeExtension
806811
tags:
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PhpParser\Node\Expr\UnaryMinus;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\Type\BooleanType;
10+
use PHPStan\Type\Constant\ConstantBooleanType;
11+
use PHPStan\Type\ConstantScalarType;
12+
use PHPStan\Type\IntegerRangeType;
13+
use PHPStan\Type\IntegerType;
14+
use PHPStan\Type\NullType;
15+
use PHPStan\Type\StringType;
16+
use PHPStan\Type\Type;
17+
use PHPStan\Type\UnionType;
18+
use function in_array;
19+
use function is_numeric;
20+
21+
class BcMathStringOrNullReturnTypeExtension implements \PHPStan\Type\DynamicFunctionReturnTypeExtension
22+
{
23+
24+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
25+
{
26+
return in_array($functionReflection->getName(), ['bcdiv', 'bcmod', 'bcpowmod', 'bcsqrt'], true);
27+
}
28+
29+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
30+
{
31+
if ($functionReflection->getName() === 'bcsqrt') {
32+
return $this->getTypeForBcSqrt($functionCall, $scope);
33+
}
34+
35+
if ($functionReflection->getName() === 'bcpowmod') {
36+
return $this->getTypeForBcPowMod($functionCall, $scope);
37+
}
38+
39+
$defaultReturnType = new UnionType([new StringType(), new NullType()]);
40+
41+
if (isset($functionCall->args[1]) === false) {
42+
return $defaultReturnType;
43+
}
44+
45+
$secondArgument = $scope->getType($functionCall->args[1]->value);
46+
$secondArgumentIsNumeric = ($secondArgument instanceof ConstantScalarType && is_numeric($secondArgument->getValue())) || $secondArgument instanceof IntegerType;
47+
48+
if ($secondArgument instanceof ConstantScalarType && ($this->isZero($secondArgument->getValue()) || !$secondArgumentIsNumeric)) {
49+
return new NullType();
50+
}
51+
52+
if (isset($functionCall->args[2]) === false) {
53+
if ($secondArgument instanceof ConstantScalarType || $secondArgumentIsNumeric) {
54+
return new StringType();
55+
}
56+
57+
return $defaultReturnType;
58+
}
59+
60+
$thirdArgument = $scope->getType($functionCall->args[2]->value);
61+
$thirdArgumentIsNumeric = ($thirdArgument instanceof ConstantScalarType && is_numeric($thirdArgument->getValue())) || $thirdArgument instanceof IntegerType;
62+
63+
if ($thirdArgument instanceof ConstantScalarType && ($this->isZero($thirdArgument->getValue()) || !is_numeric($thirdArgument->getValue()))) {
64+
return new NullType();
65+
}
66+
67+
if (($secondArgument instanceof ConstantScalarType || $secondArgumentIsNumeric) && $thirdArgumentIsNumeric) {
68+
return new StringType();
69+
}
70+
71+
return $defaultReturnType;
72+
}
73+
74+
/**
75+
* bcsqrt
76+
* https://www.php.net/manual/en/function.bcsqrt.php
77+
* > Returns the square root as a string, or NULL if operand is negative.
78+
*
79+
* @param FuncCall $functionCall
80+
* @param Scope $scope
81+
* @return NullType|StringType|UnionType
82+
*/
83+
private function getTypeForBcSqrt(FuncCall $functionCall, Scope $scope)
84+
{
85+
$defaultReturnType = new UnionType([new StringType(), new NullType()]);
86+
87+
if (isset($functionCall->args[0]) === false) {
88+
return $defaultReturnType;
89+
}
90+
91+
$firstArgument = $scope->getType($functionCall->args[0]->value);
92+
93+
$firstArgumentIsPositive = $firstArgument instanceof ConstantScalarType && is_numeric($firstArgument->getValue()) && $firstArgument->getValue() >= 0;
94+
$firstArgumentIsNegative = $firstArgument instanceof ConstantScalarType && is_numeric($firstArgument->getValue()) && $firstArgument->getValue() < 0;
95+
96+
if ($firstArgument instanceof UnaryMinus ||
97+
($firstArgumentIsNegative)) {
98+
return new NullType();
99+
}
100+
101+
if (isset($functionCall->args[1]) === false) {
102+
if ($firstArgumentIsPositive) {
103+
return new StringType();
104+
}
105+
106+
return $defaultReturnType;
107+
}
108+
109+
$secondArgument = $scope->getType($functionCall->args[1]->value);
110+
$secondArgumentIsValid = $secondArgument instanceof ConstantScalarType && is_numeric($secondArgument->getValue()) && !$this->isZero($secondArgument->getValue());
111+
$secondArgumentIsNonNumeric = $secondArgument instanceof ConstantScalarType && !is_numeric($secondArgument->getValue());
112+
113+
if ($secondArgumentIsNonNumeric) {
114+
return new NullType();
115+
}
116+
117+
if ($firstArgumentIsPositive && $secondArgumentIsValid) {
118+
return new StringType();
119+
}
120+
121+
return $defaultReturnType;
122+
}
123+
124+
/**
125+
* bcpowmod()
126+
* https://www.php.net/manual/en/function.bcpowmod.php
127+
* > Returns the result as a string, or FALSE if modulus is 0 or exponent is negative.
128+
* @param FuncCall $functionCall
129+
* @param Scope $scope
130+
* @return BooleanType|StringType|UnionType
131+
*/
132+
private function getTypeForBcPowMod(FuncCall $functionCall, Scope $scope)
133+
{
134+
if (isset($functionCall->args[1]) === false) {
135+
return new UnionType([new StringType(), new ConstantBooleanType(false)]);
136+
}
137+
138+
$exponent = $scope->getType($functionCall->args[1]->value);
139+
$exponentIsNegative = IntegerRangeType::fromInterval(null, 0)->isSuperTypeOf($exponent)->yes();
140+
141+
if ($exponent instanceof ConstantScalarType) {
142+
$exponentIsNegative = is_numeric($exponent->getValue()) && $exponent->getValue() < 0;
143+
}
144+
145+
if ($exponentIsNegative) {
146+
return new ConstantBooleanType(false);
147+
}
148+
149+
if (isset($functionCall->args[2])) {
150+
$modulus = $scope->getType($functionCall->args[2]->value);
151+
$modulusIsZero = $modulus instanceof ConstantScalarType && $this->isZero($modulus->getValue());
152+
$modulusIsNonNumeric = $modulus instanceof ConstantScalarType && !is_numeric($modulus->getValue());
153+
154+
if ($modulusIsZero || $modulusIsNonNumeric) {
155+
return new ConstantBooleanType(false);
156+
}
157+
158+
if ($modulus instanceof ConstantScalarType) {
159+
return new StringType();
160+
}
161+
}
162+
163+
return new UnionType([new StringType(), new ConstantBooleanType(false)]);
164+
}
165+
166+
/**
167+
* Utility to help us determine if value is zero. Handles cases where we pass "0.000" too.
168+
*
169+
* @param mixed $value
170+
* @return bool
171+
*/
172+
private function isZero($value): bool
173+
{
174+
if (is_numeric($value) === false) {
175+
return false;
176+
}
177+
178+
if ($value > 0 || $value < 0) {
179+
return false;
180+
}
181+
182+
return true;
183+
}
184+
185+
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10093,6 +10093,11 @@ public function dataPregSplitReturnType(): array
1009310093
return $this->gatherAssertTypes(__DIR__ . '/data/preg_split.php');
1009410094
}
1009510095

10096+
public function dataBcMathDynamicReturn(): array
10097+
{
10098+
return $this->gatherAssertTypes(__DIR__ . '/data/bcmath-dynamic-return.php');
10099+
}
10100+
1009610101
/**
1009710102
* @dataProvider dataBug2574
1009810103
* @dataProvider dataBug2577
@@ -10158,6 +10163,7 @@ public function dataPregSplitReturnType(): array
1015810163
* @dataProvider dataNativeStaticReturnType
1015910164
* @dataProvider dataClassPhpDocs
1016010165
* @dataProvider dataNonEmptyArrayKeyType
10166+
* @dataProvider dataBcMathDynamicReturn
1016110167
* @dataProvider dataBug3133
1016210168
* @dataProvider dataBug2550
1016310169
* @dataProvider dataBug2899
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
// Verification for constant types: https://3v4l.org/96GSj
4+
5+
/** @var mixed $mixed */
6+
$mixed = getMixed();
7+
8+
/** @var int $iUnknown */
9+
$iUnknown = getInt();
10+
11+
/** @var string $string */
12+
$string = getString();
13+
14+
$iNeg = -5;
15+
$iPos = 5;
16+
$nonNumeric = 'foo';
17+
18+
19+
// bcdiv ( string $dividend , string $divisor [, int $scale = 0 ] ) : string
20+
// Returns the result of the division as a string, or NULL if divisor is 0.
21+
\PHPStan\Analyser\assertType('null', bcdiv('10', '0')); // Warning: Division by zero
22+
\PHPStan\Analyser\assertType('null', bcdiv('10', '0.0')); // Warning: Division by zero
23+
\PHPStan\Analyser\assertType('null', bcdiv('10', 0.0)); // Warning: Division by zero
24+
\PHPStan\Analyser\assertType('string', bcdiv('10', '1'));
25+
\PHPStan\Analyser\assertType('string', bcdiv('10', '-1'));
26+
\PHPStan\Analyser\assertType('string', bcdiv('10', $iNeg));
27+
\PHPStan\Analyser\assertType('string', bcdiv('10', $iPos));
28+
\PHPStan\Analyser\assertType('string', bcdiv($iPos, $iPos));
29+
\PHPStan\Analyser\assertType('string|null', bcdiv('10', $mixed));
30+
\PHPStan\Analyser\assertType('string', bcdiv('10', $iPos, $iPos));
31+
\PHPStan\Analyser\assertType('string', bcdiv('10', $iUnknown));
32+
\PHPStan\Analyser\assertType('null', bcdiv('10', $iPos, $nonNumeric)); // Warning: expects parameter 3 to be int, string given in
33+
\PHPStan\Analyser\assertType('null', bcdiv('10', $nonNumeric)); // Warning: bcmath function argument is not well-formed
34+
35+
// bcmod ( string $dividend , string $divisor [, int $scale = 0 ] ) : string
36+
// Returns the modulus as a string, or NULL if divisor is 0.
37+
\PHPStan\Analyser\assertType('null', bcmod('10', '0'));
38+
\PHPStan\Analyser\assertType('null', bcmod($iPos, '0')); // Warning: Division by zero
39+
\PHPStan\Analyser\assertType('null', bcmod('10', $nonNumeric));
40+
\PHPStan\Analyser\assertType('string', bcmod('10', '1'));
41+
\PHPStan\Analyser\assertType('string', bcmod('10', 2.2));
42+
\PHPStan\Analyser\assertType('string', bcmod('10', $iUnknown));
43+
\PHPStan\Analyser\assertType('string', bcmod('10', '-1'));
44+
\PHPStan\Analyser\assertType('string', bcmod($iPos, '-1'));
45+
\PHPStan\Analyser\assertType('string', bcmod('10', $iNeg));
46+
\PHPStan\Analyser\assertType('string', bcmod('10', $iPos));
47+
\PHPStan\Analyser\assertType('string', bcmod('10', -$iNeg));
48+
\PHPStan\Analyser\assertType('string', bcmod('10', -$iPos));
49+
\PHPStan\Analyser\assertType('string|null', bcmod('10', $mixed));
50+
51+
// bcpowmod ( string $base , string $exponent , string $modulus [, int $scale = 0 ] ) : string
52+
// Returns the result as a string, or FALSE if modulus is 0 or exponent is negative.
53+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '-2', '0')); // exponent negative, and modulus is 0
54+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '-2', '1')); // exponent negative
55+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '2', $nonNumeric)); // Warning: bcmath function argument is not well-formed
56+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '-2', '-1')); // exponent negative
57+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '-2', -1.3)); // exponent negative
58+
\PHPStan\Analyser\assertType('false', bcpowmod('10', -$iPos, '-1')); // exponent negative
59+
\PHPStan\Analyser\assertType('false', bcpowmod('10', -$iPos, '1')); // exponent negative
60+
\PHPStan\Analyser\assertType('false', bcpowmod('10', $nonNumeric, $nonNumeric)); // Warning: bcmath function argument is not well-formed
61+
\PHPStan\Analyser\assertType('false', bcpowmod($iPos, $nonNumeric, $nonNumeric));
62+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '2', '0')); // modulus is 0
63+
\PHPStan\Analyser\assertType('false', bcpowmod('10', 2.3, '0')); // modulus is 0
64+
\PHPStan\Analyser\assertType('false', bcpowmod('10', '0', '0')); // modulus is 0
65+
\PHPStan\Analyser\assertType('string', bcpowmod('10', '0', '-2'));
66+
\PHPStan\Analyser\assertType('string', bcpowmod('10', '2', '2'));
67+
\PHPStan\Analyser\assertType('string', bcpowmod('10', $iUnknown, '2'));
68+
\PHPStan\Analyser\assertType('string', bcpowmod($iPos, '2', '2'));
69+
\PHPStan\Analyser\assertType('string|false', bcpowmod('10', $mixed, $mixed));
70+
\PHPStan\Analyser\assertType('string', bcpowmod('10', '2', '2'));
71+
\PHPStan\Analyser\assertType('string', bcpowmod('10', -$iNeg, '2'));
72+
\PHPStan\Analyser\assertType('string', bcpowmod('10', $nonNumeric, '2')); // Warning: bcmath function argument is not well-formed
73+
\PHPStan\Analyser\assertType('string|false', bcpowmod('10', $iUnknown, $iUnknown));
74+
75+
// bcsqrt ( string $operand [, int $scale = 0 ] ) : string
76+
// Returns the square root as a string, or NULL if operand is negative.
77+
\PHPStan\Analyser\assertType('string', bcsqrt('10', $iNeg));
78+
\PHPStan\Analyser\assertType('string', bcsqrt('10', 1));
79+
\PHPStan\Analyser\assertType('string', bcsqrt('0.00', 1));
80+
\PHPStan\Analyser\assertType('string', bcsqrt(0.0, 1));
81+
\PHPStan\Analyser\assertType('string', bcsqrt('0', 1));
82+
\PHPStan\Analyser\assertType('string|null', bcsqrt($iUnknown, $iUnknown));
83+
\PHPStan\Analyser\assertType('string', bcsqrt('10', $iPos));
84+
\PHPStan\Analyser\assertType('null', bcsqrt('-10', 0)); // Warning: Square root of negative number
85+
\PHPStan\Analyser\assertType('null', bcsqrt($iNeg, 0));
86+
\PHPStan\Analyser\assertType('null', bcsqrt('10', $nonNumeric)); // Warning: Second argument must be ?int (Fatal in PHP8)
87+
\PHPStan\Analyser\assertType('string', bcsqrt('10'));
88+
\PHPStan\Analyser\assertType('string|null', bcsqrt($iUnknown));
89+
\PHPStan\Analyser\assertType('null', bcsqrt('-10')); // Warning: Square root of negative number
90+
91+
\PHPStan\Analyser\assertType('string|null', bcsqrt($nonNumeric, -1)); // Warning: bcmath function argument is not well-formed
92+
\PHPStan\Analyser\assertType('string|null', bcsqrt('10', $mixed));
93+
\PHPStan\Analyser\assertType('string', bcsqrt($iPos));

0 commit comments

Comments
 (0)