Skip to content

Commit ac7b886

Browse files
authored
Fix substr() type narrowing for possibly single char result
1 parent b650df6 commit ac7b886

File tree

7 files changed

+100
-4
lines changed

7 files changed

+100
-4
lines changed

src/Type/Php/SubstrDynamicReturnTypeExtension.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ public function getTypeFromFunctionCall(
4949
$zeroOffset = (new ConstantIntegerType(0))->isSuperTypeOf($offset)->yes();
5050
$length = null;
5151
$positiveLength = false;
52+
$maybeOneLength = false;
5253

5354
if (count($args) === 3) {
5455
$length = $scope->getType($args[2]->value);
5556
$positiveLength = IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($length)->yes();
57+
$maybeOneLength = !(new ConstantIntegerType(1))->isSuperTypeOf($length)->no();
5658
}
5759

5860
$constantStrings = $string->getConstantStrings();
@@ -88,7 +90,7 @@ public function getTypeFromFunctionCall(
8890
}
8991

9092
if ($string->isNonEmptyString()->yes() && ($negativeOffset || $zeroOffset && $positiveLength)) {
91-
if ($string->isNonFalsyString()->yes()) {
93+
if ($string->isNonFalsyString()->yes() && !$maybeOneLength) {
9294
return new IntersectionType([
9395
new StringType(),
9496
new AccessoryNonFalsyStringType(),

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,7 @@ public function dataFileAsserts(): iterable
14901490
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6613.php');
14911491
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10187.php');
14921492
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10834.php');
1493+
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-11035.php');
14931494
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10952.php');
14941495
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10952b.php');
14951496
yield from $this->gatherAssertTypes(__DIR__ . '/data/case-insensitive-parent.php');
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug11035;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function phone_number_starts_with_zero(string $phone): string
8+
{
9+
if (
10+
(12 === strlen($phone) and '380' === substr($phone, 0, 3)) or
11+
(11 === strlen($phone) and '80' === substr($phone, 0, 2)) or
12+
(10 === strlen($phone) and '0' === substr($phone, 0, 1))
13+
) {
14+
$phone = '+';
15+
} else {
16+
$phone = '';
17+
}
18+
return $phone;
19+
}
20+
21+
/**
22+
* @param int<1,3> $maybeOne
23+
* @param int<2,10> $neverOne
24+
*/
25+
function lengthTypes(string $phone, int $maybeOne, int $neverOne): string
26+
{
27+
if (
28+
10 === strlen($phone)
29+
) {
30+
assertType('non-falsy-string', $phone);
31+
32+
assertType('non-empty-string', substr($phone, 0, 1));
33+
assertType('bool', '0' === substr($phone, 0, 1));
34+
35+
assertType('non-empty-string', substr($phone, 0, $maybeOne));
36+
assertType('bool', '0' === substr($phone, 0, $maybeOne));
37+
38+
assertType('non-falsy-string', substr($phone, 0, $neverOne));
39+
assertType('false', '0' === substr($phone, 0, $neverOne));
40+
}
41+
}

tests/PHPStan/Analyser/data/non-empty-string-substr.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function doMbSubstr(string $s, $nonEmpty, $positiveInt, $postiveRange, $n
5454
assertType('string', mb_substr($s, 0, $positiveInt));
5555
assertType('non-empty-string', mb_substr($nonEmpty, 0, $positiveInt));
5656

57-
assertType('non-falsy-string', mb_substr("déjà_vu", 0, $positiveInt));
57+
assertType('non-empty-string', mb_substr("déjà_vu", 0, $positiveInt));
5858
assertType("'déjà_vu'", mb_substr("déjà_vu", 0));
5959
assertType("'déj'", mb_substr("déjà_vu", 0, 3));
6060
}

tests/PHPStan/Analyser/data/non-falsy-string.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ public function doSubstr($nonFalsey, $positiveInt, $postiveRange, $negativeRange
126126
assertType('non-falsy-string', substr($nonFalsey, $negativeRange));
127127

128128
assertType('non-falsy-string', substr($nonFalsey, 0, 5));
129-
assertType('non-falsy-string', substr($nonFalsey, 0, $postiveRange));
129+
assertType('non-empty-string', substr($nonFalsey, 0, $postiveRange));
130130

131-
assertType('non-falsy-string', substr($nonFalsey, 0, $positiveInt));
131+
assertType('non-empty-string', substr($nonFalsey, 0, $positiveInt));
132132
}
133133

134134
function numericIntoFalsy(string $s): void

tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,4 +1022,22 @@ public function testBug3300(): void
10221022
$this->analyse([__DIR__ . '/../../Analyser/data/bug-3300.php'], []);
10231023
}
10241024

1025+
public function testBug11035(): void
1026+
{
1027+
$this->checkAlwaysTrueStrictComparison = true;
1028+
$this->analyse([__DIR__ . '/../../Analyser/data/bug-11035.php'], [
1029+
[
1030+
"Strict comparison using === between '0' and non-falsy-string will always evaluate to false.",
1031+
39,
1032+
'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.',
1033+
],
1034+
]);
1035+
}
1036+
1037+
public function testBug9804(): void
1038+
{
1039+
$this->checkAlwaysTrueStrictComparison = true;
1040+
$this->analyse([__DIR__ . '/data/bug-9804.php'], []);
1041+
}
1042+
10251043
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Bug9804;
4+
5+
class Test
6+
{
7+
public function error(?string $someString): void
8+
{
9+
// Line below is the only difference to "pass" method
10+
if (!$someString) {
11+
return;
12+
}
13+
14+
// Strict comparison using === between int<min, -1>|int<1, max> and 0 will always evaluate to false.
15+
$firstLetterAsInt = (int)substr($someString, 0, 1);
16+
if ($firstLetterAsInt === 0) {
17+
return;
18+
}
19+
}
20+
21+
public function pass(?string $someString): void
22+
{
23+
// Line below is the only difference to "error" method
24+
if ($someString === null) {
25+
return;
26+
}
27+
28+
// All ok
29+
$firstLetterAsInt = (int)substr($someString, 0, 1);
30+
if ($firstLetterAsInt === 0) {
31+
return;
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)