Skip to content

Commit 04d314d

Browse files
phpstan-botstaabmclaude
authored
Narrow preg_match/preg_match_all subject string type when match is truthy (#5777)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent c8db9f9 commit 04d314d

3 files changed

Lines changed: 172 additions & 4 deletions

File tree

src/Type/Php/PregMatchTypeSpecifyingExtension.php

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace PHPStan\Type\Php;
44

5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Expr\ArrayDimFetch;
57
use PhpParser\Node\Expr\FuncCall;
68
use PHPStan\Analyser\Scope;
79
use PHPStan\Analyser\SpecifiedTypes;
@@ -41,13 +43,34 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
4143
{
4244
$args = $node->getArgs();
4345
$patternArg = $args[0] ?? null;
46+
$subjectArg = $args[1] ?? null;
4447
$matchesArg = $args[2] ?? null;
4548
$flagsArg = $args[3] ?? null;
4649

50+
if ($patternArg === null) {
51+
return new SpecifiedTypes();
52+
}
53+
54+
$subjectTypes = new SpecifiedTypes();
4755
if (
48-
$patternArg === null || $matchesArg === null
56+
$subjectArg !== null
57+
&& $context->true()
58+
&& $scope->getType($subjectArg->value)->isString()->yes()
59+
&& !$this->isSubExprOfMatchesArg($subjectArg->value, $matchesArg !== null ? $matchesArg->value : null)
4960
) {
50-
return new SpecifiedTypes();
61+
$subjectType = $this->regexShapeMatcher->matchSubjectExpr($patternArg->value, $scope);
62+
if ($subjectType !== null) {
63+
$subjectTypes = $this->typeSpecifier->create(
64+
$subjectArg->value,
65+
$subjectType,
66+
$context,
67+
$scope,
68+
)->setRootExpr($node);
69+
}
70+
}
71+
72+
if ($matchesArg === null) {
73+
return $subjectTypes;
5174
}
5275

5376
$flagsType = null;
@@ -69,7 +92,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
6992
$matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, $wasMatched, $scope);
7093
}
7194
if ($matchedType === null) {
72-
return new SpecifiedTypes();
95+
return $subjectTypes;
7396
}
7497

7598
$overwrite = false;
@@ -88,7 +111,21 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
88111
$types = $types->setAlwaysOverwriteTypes();
89112
}
90113

91-
return $types;
114+
return $types->unionWith($subjectTypes);
115+
}
116+
117+
private function isSubExprOfMatchesArg(Expr $subject, ?Expr $matchesVar): bool
118+
{
119+
if ($matchesVar === null) {
120+
return false;
121+
}
122+
$rootVar = $subject;
123+
while ($rootVar instanceof ArrayDimFetch) {
124+
$rootVar = $rootVar->var;
125+
}
126+
return $rootVar instanceof Expr\Variable
127+
&& $matchesVar instanceof Expr\Variable
128+
&& $rootVar->name === $matchesVar->name;
92129
}
93130

94131
}

src/Type/Php/RegexArrayShapeMatcher.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,27 @@ public function matchExpr(Expr $patternExpr, ?Type $flagsType, TrinaryLogic $was
6161
return $this->matchPatternType($this->getPatternType($patternExpr, $scope), $flagsType, $wasMatched, false);
6262
}
6363

64+
public function matchSubjectExpr(Expr $patternExpr, Scope $scope): ?Type
65+
{
66+
$patternType = $this->getPatternType($patternExpr, $scope);
67+
$constantStrings = $patternType->getConstantStrings();
68+
if (count($constantStrings) === 0) {
69+
return null;
70+
}
71+
72+
$subjectTypes = [];
73+
foreach ($constantStrings as $constantString) {
74+
$astWalkResult = $this->regexGroupParser->parseGroups($constantString->getValue());
75+
if ($astWalkResult === null) {
76+
return null;
77+
}
78+
79+
$subjectTypes[] = $astWalkResult->getSubjectBaseType();
80+
}
81+
82+
return TypeCombinator::union(...$subjectTypes);
83+
}
84+
6485
private function matchPatternType(Type $patternType, ?Type $flagsType, TrinaryLogic $wasMatched, bool $matchesAll): ?Type
6586
{
6687
if ($wasMatched->no()) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14710;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function ternaryPregMatch(string $x): void {
8+
(preg_match('/^(a|b|c)$/', $x)) ?
9+
assertType('non-falsy-string', $x)
10+
: assertType('string', $x);
11+
}
12+
13+
function ifPregMatch(string $x): void {
14+
if (preg_match('/^(a|b|c)$/', $x)) {
15+
assertType('non-falsy-string', $x);
16+
} else {
17+
assertType('string', $x);
18+
}
19+
}
20+
21+
function ternaryPregMatchWithMatches(string $x): void {
22+
(preg_match('/^(a|b|c)$/', $x, $matches)) ?
23+
assertType('non-falsy-string', $x)
24+
: assertType('string', $x);
25+
}
26+
27+
function ifPregMatchWithMatches(string $x): void {
28+
if (preg_match('/^(a|b|c)$/', $x, $matches)) {
29+
assertType('non-falsy-string', $x);
30+
} else {
31+
assertType('string', $x);
32+
}
33+
}
34+
35+
function pregMatchNonEmpty(string $x): void {
36+
if (preg_match('/foo/', $x)) {
37+
assertType('non-falsy-string', $x);
38+
}
39+
}
40+
41+
function pregMatchNoNarrow(string $x): void {
42+
if (preg_match('/^$/', $x)) {
43+
assertType('string', $x);
44+
}
45+
}
46+
47+
function pregMatchAll(string $x): void {
48+
if (preg_match_all('/^(a|b|c)$/', $x)) {
49+
assertType('non-falsy-string', $x);
50+
} else {
51+
assertType('string', $x);
52+
}
53+
}
54+
55+
function negatedPregMatch(string $x): void {
56+
if (!preg_match('/^(a|b|c)$/', $x)) {
57+
assertType('string', $x);
58+
} else {
59+
assertType('non-falsy-string', $x);
60+
}
61+
}
62+
63+
function pregMatchCompare(string $x): void {
64+
if (preg_match('/^(a|b|c)$/', $x) === 1) {
65+
assertType('non-falsy-string', $x);
66+
}
67+
}
68+
69+
function pregMatchNotIdentical(string $x): void {
70+
if (preg_match('#ExtensionInterface$#', $x) !== 1) {
71+
return;
72+
}
73+
assertType('non-falsy-string', $x);
74+
}
75+
76+
function pregMatchNotEqual(string $x): void {
77+
if (preg_match('#ExtensionInterface$#', $x) != 1) {
78+
return;
79+
}
80+
assertType('non-falsy-string', $x);
81+
}
82+
83+
function pregMatchWithNonConstantPattern(string $pattern, string $x): void {
84+
if (preg_match($pattern, $x)) {
85+
assertType('string', $x);
86+
}
87+
}
88+
89+
function pregMatchSubjectSharesVarWithMatches(): void {
90+
$matches = ['', '', 'foo'];
91+
if (preg_match('/^(a|b|c)$/', $matches[2], $matches)) {
92+
assertType("array{non-falsy-string, 'a'|'b'|'c'}", $matches);
93+
}
94+
}
95+
96+
function pregMatchNullableSubject(?string $x): void {
97+
// a null subject is coerced to '' which cannot match a non-empty pattern, so null is removed
98+
if (preg_match('/^(a|b|c)$/', $x)) {
99+
assertType('string|null', $x); // could be non-falsy-string
100+
} else {
101+
assertType('string|null', $x);
102+
}
103+
}
104+
105+
function pregMatchIntStringSubject(int|string $x): void {
106+
// an int subject can be coerced to a matching string, so narrowing it away would be unsound
107+
if (preg_match('/^(a|b|c)$/', $x)) {
108+
assertType('int|string', $x);
109+
}
110+
}

0 commit comments

Comments
 (0)