Skip to content

Commit eafa5ab

Browse files
authored
Expand character ranges when trimming a numeric-string (#7013)
1 parent 8b969c7 commit eafa5ab

5 files changed

Lines changed: 111 additions & 4 deletions

File tree

‎src/Type/Php/LtrimFunctionReturnTypeExtension.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818
use PHPStan\Type\TypeCombinator;
1919
use function count;
2020
use function ltrim;
21-
use function preg_match;
2221

2322
#[AutowiredService]
2423
final class LtrimFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
2524
{
2625

26+
public function __construct(
27+
private TrimCharactersHelper $trimCharactersHelper,
28+
)
29+
{
30+
}
31+
2732
public function isFunctionSupported(FunctionReflection $functionReflection): bool
2833
{
2934
return $functionReflection->getName() === 'ltrim';
@@ -72,7 +77,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
7277
}
7378
} elseif ($trimConstantString->getValue() === '\\' && $string->isClassString()->yes()) {
7479
$result[] = new ClassStringType();
75-
} elseif (preg_match('/\d/', $trimConstantString->getValue()) === 0 && $string->isNumericString()->yes()) {
80+
} elseif (!$this->trimCharactersHelper->containsDigit($trimConstantString->getValue()) && $string->isNumericString()->yes()) {
7681
$result[] = new IntersectionType([new StringType(), new AccessoryNumericStringType()]);
7782
} else {
7883
return $defaultType;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PHPStan\DependencyInjection\AutowiredService;
6+
use function ord;
7+
use function strlen;
8+
9+
#[AutowiredService]
10+
final class TrimCharactersHelper
11+
{
12+
13+
/**
14+
* Reads the character list like PHP's php_charmask(): "a..z" stands for
15+
* every byte from "a" to "z", so "/..:" contains the digits.
16+
*/
17+
public function containsDigit(string $characters): bool
18+
{
19+
$length = strlen($characters);
20+
for ($i = 0; $i < $length; $i++) {
21+
$byte = ord($characters[$i]);
22+
if (
23+
$i + 3 < $length
24+
&& $characters[$i + 1] === '.'
25+
&& $characters[$i + 2] === '.'
26+
&& ord($characters[$i + 3]) >= $byte
27+
) {
28+
if ($byte <= ord('9') && ord($characters[$i + 3]) >= ord('0')) {
29+
return true;
30+
}
31+
32+
$i += 3;
33+
continue;
34+
}
35+
36+
if ($byte >= ord('0') && $byte <= ord('9')) {
37+
return true;
38+
}
39+
}
40+
41+
return false;
42+
}
43+
44+
}

‎src/Type/Php/TrimFunctionDynamicReturnTypeExtension.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@
1717
use PHPStan\Type\TypeCombinator;
1818
use function count;
1919
use function in_array;
20-
use function preg_match;
2120
use function rtrim;
2221
use function trim;
2322

2423
#[AutowiredService]
2524
final class TrimFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
2625
{
2726

27+
public function __construct(
28+
private TrimCharactersHelper $trimCharactersHelper,
29+
)
30+
{
31+
}
32+
2833
public function isFunctionSupported(FunctionReflection $functionReflection): bool
2934
{
3035
return in_array($functionReflection->getName(), ['trim', 'rtrim', 'chop'], true);
@@ -77,7 +82,7 @@ public function getTypeFromFunctionCall(
7782
true,
7883
);
7984
}
80-
} elseif (preg_match('/\d/', $trimConstantString->getValue()) === 0 && $stringType->isNumericString()->yes()) {
85+
} elseif (!$this->trimCharactersHelper->containsDigit($trimConstantString->getValue()) && $stringType->isNumericString()->yes()) {
8186
$result[] = new IntersectionType([new StringType(), new AccessoryNumericStringType()]);
8287
} else {
8388
return $defaultType;

‎tests/PHPStan/Analyser/nsrt/numeric-string-trim.php‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ public function doTrim(string $numericString, string $string): void
2828
assertType('string', trim($numericString, '-0'));
2929
assertType('string', ltrim($numericString, '-0'));
3030
assertType('string', rtrim($numericString, '-0'));
31+
32+
assertType('string', trim($numericString, '/..:'));
33+
assertType('string', ltrim($numericString, '/..:'));
34+
assertType('string', rtrim($numericString, '/..:'));
35+
assertType('string', chop($numericString, '/..:'));
36+
assertType('string', trim($numericString, '!..~'));
37+
38+
assertType('numeric-string', trim($numericString, 'a..z'));
39+
assertType('numeric-string', trim($numericString, '..:'));
40+
assertType('numeric-string', trim($numericString, ':../'));
3141
}
3242

3343
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class TrimCharactersHelperTest extends TestCase
9+
{
10+
11+
public static function dataContainsDigit(): iterable
12+
{
13+
yield ['', false];
14+
yield ['abc', false];
15+
yield ['0', true];
16+
yield ['9', true];
17+
yield ['a5', true];
18+
yield ['0..9', true];
19+
yield ['9..9', true];
20+
yield ['/..:', true];
21+
yield ['!..~', true];
22+
yield ['/..0', true];
23+
yield ["\x00..\xff", true];
24+
yield ['a..z', false];
25+
yield [':..~', false];
26+
yield [' ../', false];
27+
yield ['a..z5', true];
28+
yield ['...0', true];
29+
30+
// invalid ranges add no range, only the characters themselves
31+
yield ['..:', false];
32+
yield [':../', false];
33+
yield ['a..', false];
34+
yield ['5..', true];
35+
}
36+
37+
#[DataProvider('dataContainsDigit')]
38+
public function testContainsDigit(string $characters, bool $expected): void
39+
{
40+
$this->assertSame($expected, (new TrimCharactersHelper())->containsDigit($characters));
41+
}
42+
43+
}

0 commit comments

Comments
 (0)