Skip to content

Commit 49b8b26

Browse files
authored
implemented str_contains FunctionTypeSpecifyingExtension
1 parent 7e1e51d commit 49b8b26

6 files changed

+259
-0
lines changed

conf/config.neon

+5
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,11 @@ services:
12681268
tags:
12691269
- phpstan.dynamicStaticMethodThrowTypeExtension
12701270

1271+
-
1272+
class: PHPStan\Type\Php\StrContainingTypeSpecifyingExtension
1273+
tags:
1274+
- phpstan.typeSpecifier.functionTypeSpecifyingExtension
1275+
12711276
-
12721277
class: PHPStan\Type\Php\SimpleXMLElementClassPropertyReflectionExtension
12731278
tags:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Arg;
6+
use PhpParser\Node\Expr\BinaryOp\BooleanAnd;
7+
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
8+
use PhpParser\Node\Expr\FuncCall;
9+
use PhpParser\Node\Name;
10+
use PhpParser\Node\Scalar\String_;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Analyser\SpecifiedTypes;
13+
use PHPStan\Analyser\TypeSpecifier;
14+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
15+
use PHPStan\Analyser\TypeSpecifierContext;
16+
use PHPStan\Reflection\FunctionReflection;
17+
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
18+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
19+
use PHPStan\Type\Accessory\AccessoryNumericStringType;
20+
use PHPStan\Type\FunctionTypeSpecifyingExtension;
21+
use PHPStan\Type\IntersectionType;
22+
use PHPStan\Type\StringType;
23+
use function array_key_exists;
24+
use function count;
25+
use function strtolower;
26+
27+
final class StrContainingTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension
28+
{
29+
30+
/** @var array<string, array{0: int, 1: int}> */
31+
private array $strContainingFunctions = [
32+
'fnmatch' => [1, 0],
33+
'str_contains' => [0, 1],
34+
'str_starts_with' => [0, 1],
35+
'str_ends_with' => [0, 1],
36+
'strpos' => [0, 1],
37+
'strrpos' => [0, 1],
38+
'stripos' => [0, 1],
39+
'strripos' => [0, 1],
40+
'strstr' => [0, 1],
41+
];
42+
43+
private TypeSpecifier $typeSpecifier;
44+
45+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
46+
{
47+
$this->typeSpecifier = $typeSpecifier;
48+
}
49+
50+
public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool
51+
{
52+
return array_key_exists(strtolower($functionReflection->getName()), $this->strContainingFunctions)
53+
&& $context->truthy();
54+
}
55+
56+
public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
57+
{
58+
$args = $node->getArgs();
59+
60+
if (count($args) >= 2) {
61+
[$hackstackArg, $needleArg] = $this->strContainingFunctions[strtolower($functionReflection->getName())];
62+
63+
$haystackType = $scope->getType($args[$hackstackArg]->value);
64+
$needleType = $scope->getType($args[$needleArg]->value);
65+
66+
if ($needleType->isNonEmptyString()->yes() && $haystackType->isString()->yes()) {
67+
$accessories = [
68+
new StringType(),
69+
new AccessoryNonEmptyStringType(),
70+
];
71+
72+
if ($haystackType->isLiteralString()->yes()) {
73+
$accessories[] = new AccessoryLiteralStringType();
74+
}
75+
if ($haystackType->isNumericString()->yes()) {
76+
$accessories[] = new AccessoryNumericStringType();
77+
}
78+
79+
return $this->typeSpecifier->create(
80+
$args[$hackstackArg]->value,
81+
new IntersectionType($accessories),
82+
$context,
83+
false,
84+
$scope,
85+
new BooleanAnd(
86+
new NotIdentical(
87+
$args[$needleArg]->value,
88+
new String_(''),
89+
),
90+
new FuncCall(new Name('FAUX_FUNCTION'), [
91+
new Arg($args[$needleArg]->value),
92+
]),
93+
),
94+
);
95+
}
96+
}
97+
98+
return new SpecifiedTypes();
99+
}
100+
101+
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

+1
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,7 @@ public function dataFileAsserts(): iterable
902902
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7068.php');
903903
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7115.php');
904904
yield from $this->gatherAssertTypes(__DIR__ . '/data/constant-array-type-identical.php');
905+
yield from $this->gatherAssertTypes(__DIR__ . '/data/non-empty-string-str-containing-fns.php');
905906
}
906907

907908
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace NonEmptyStringStrContains;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo {
8+
/**
9+
* @param non-empty-string $nonES
10+
* @param numeric-string $numS
11+
* @param literal-string $literalS
12+
* @param non-empty-string&numeric-string $nonEAndNumericS
13+
*/
14+
public function strContains(string $s, string $s2, $nonES, $numS, $literalS, $nonEAndNumericS, int $i): void
15+
{
16+
if (str_contains($s, ':')) {
17+
assertType('non-empty-string', $s);
18+
}
19+
assertType('string', $s);
20+
21+
if (str_contains($s, $s2)) {
22+
assertType('string', $s);
23+
}
24+
25+
if (str_contains($s, $nonES)) {
26+
assertType('non-empty-string', $s);
27+
}
28+
if (str_contains($s, $numS)) {
29+
assertType('non-empty-string', $s);
30+
}
31+
if (str_contains($s, $literalS)) {
32+
assertType('string', $s);
33+
}
34+
35+
if (str_contains($s, $nonEAndNumericS)) {
36+
assertType('non-empty-string', $s);
37+
}
38+
if (str_contains($numS, $nonEAndNumericS)) {
39+
assertType('non-empty-string&numeric-string', $numS);
40+
}
41+
42+
if (str_contains($nonES, $s)) {
43+
assertType('non-empty-string', $nonES);
44+
}
45+
if (str_contains($nonEAndNumericS, $s)) {
46+
assertType('non-empty-string&numeric-string', $nonEAndNumericS);
47+
}
48+
49+
if (str_contains($i, $s2)) {
50+
assertType('int', $i);
51+
}
52+
}
53+
54+
public function variants(string $s) {
55+
if (fnmatch("*gr[ae]y", $s)) {
56+
assertType('non-empty-string', $s);
57+
}
58+
assertType('string', $s);
59+
60+
if (str_starts_with($s, ':')) {
61+
assertType('non-empty-string', $s);
62+
}
63+
assertType('string', $s);
64+
65+
if (str_ends_with($s, ':')) {
66+
assertType('non-empty-string', $s);
67+
}
68+
assertType('string', $s);
69+
70+
if (strpos($s, ':') !== false) {
71+
assertType('non-empty-string', $s);
72+
}
73+
assertType('string', $s);
74+
if (strpos($s, ':') === false) {
75+
assertType('string', $s);
76+
}
77+
assertType('string', $s);
78+
79+
if (strpos($s, ':') === 5) {
80+
assertType('string', $s); // could be non-empty-string
81+
}
82+
assertType('string', $s);
83+
if (strpos($s, ':') !== 5) {
84+
assertType('string', $s);
85+
}
86+
assertType('string', $s);
87+
88+
if (strrpos($s, ':') !== false) {
89+
assertType('non-empty-string', $s);
90+
}
91+
assertType('string', $s);
92+
93+
if (stripos($s, ':') !== false) {
94+
assertType('non-empty-string', $s);
95+
}
96+
assertType('string', $s);
97+
98+
if (strripos($s, ':') !== false) {
99+
assertType('non-empty-string', $s);
100+
}
101+
assertType('string', $s);
102+
103+
if (strstr($s, ':') === 'hallo') {
104+
assertType('string', $s); // could be non-empty-string
105+
}
106+
assertType('string', $s);
107+
if (strstr($s, ':', true) === 'hallo') {
108+
assertType('string', $s); // could be non-empty-string
109+
}
110+
assertType('string', $s);
111+
if (strstr($s, ':', true) !== false) {
112+
assertType('non-empty-string', $s);
113+
}
114+
assertType('string', $s);
115+
if (strstr($s, ':', true) === false) {
116+
assertType('string', $s);
117+
} else {
118+
assertType('non-empty-string', $s);
119+
}
120+
assertType('string', $s);
121+
}
122+
123+
}

tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php

+7
Original file line numberDiff line numberDiff line change
@@ -528,4 +528,11 @@ public function testSlevomatCsInArrayBug(): void
528528
$this->analyse([__DIR__ . '/data/slevomat-cs-in-array.php'], []);
529529
}
530530

531+
public function testNonEmptySpecifiedString(): void
532+
{
533+
$this->checkAlwaysTrueCheckTypeFunctionCall = true;
534+
$this->treatPhpDocTypesAsCertain = true;
535+
$this->analyse([__DIR__ . '/data/non-empty-string-impossible-type.php'], []);
536+
}
537+
531538
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace NonEmptyStringImpossibleType;
4+
5+
class Foo {
6+
private function isPrefixedInterface(string $shortClassName): bool
7+
{
8+
if (strlen($shortClassName) <= 3) {
9+
return false;
10+
}
11+
12+
if (! \str_starts_with($shortClassName, 'I')) {
13+
return false;
14+
}
15+
16+
if (! ctype_upper($shortClassName[1])) {
17+
return false;
18+
}
19+
20+
return ctype_lower($shortClassName[2]);
21+
}
22+
}

0 commit comments

Comments
 (0)