Skip to content

Commit 792edea

Browse files
authored
Infer non-empty-list for preg_match_all() matches when the match succeeded (#5812)
1 parent 5c0bbad commit 792edea

2 files changed

Lines changed: 56 additions & 26 deletions

File tree

src/Type/Php/RegexArrayShapeMatcher.php

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\Php\PhpVersion;
99
use PHPStan\TrinaryLogic;
1010
use PHPStan\Type\Accessory\AccessoryArrayListType;
11+
use PHPStan\Type\Accessory\NonEmptyArrayType;
1112
use PHPStan\Type\ArrayType;
1213
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
1314
use PHPStan\Type\Constant\ConstantIntegerType;
@@ -170,7 +171,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched
170171
if (!$this->containsUnmatchedAsNull($flags, $matchesAll)) {
171172
// positive match has a subject but not any capturing group
172173
$builder = ConstantArrayTypeBuilder::createEmpty();
173-
$builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll));
174+
$builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched));
174175

175176
$combiType = TypeCombinator::union(
176177
$builder->getArray(),
@@ -233,7 +234,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched
233234
) {
234235
// positive match has a subject but not any capturing group
235236
$builder = ConstantArrayTypeBuilder::createEmpty();
236-
$builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll));
237+
$builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched));
237238

238239
$combiTypes[] = $builder->getArray();
239240
}
@@ -273,7 +274,7 @@ private function buildArrayType(
273274
// first item in matches contains the overall match.
274275
$builder->setOffsetValueType(
275276
$this->getKeyType(0),
276-
$this->createSubjectValueType($subjectBaseType, $flags, $matchesAll),
277+
$this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched),
277278
$this->isSubjectOptional($wasMatched, $matchesAll),
278279
);
279280

@@ -317,7 +318,12 @@ private function buildArrayType(
317318
}
318319

319320
if ($matchesAll && $this->containsSetOrder($flags)) {
320-
return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $builder->getArray()), new AccessoryArrayListType()]);
321+
$accessoryTypes = [new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $builder->getArray()), new AccessoryArrayListType()];
322+
if ($wasMatched->yes()) {
323+
$accessoryTypes[] = new NonEmptyArrayType();
324+
}
325+
326+
return new IntersectionType($accessoryTypes);
321327
}
322328

323329
if ($forceList) {
@@ -339,18 +345,23 @@ private function isSubjectOptional(TrinaryLogic $wasMatched, bool $matchesAll):
339345
/**
340346
* @param Type $baseType A string type (or string variant) representing the subject of the match
341347
*/
342-
private function createSubjectValueType(Type $baseType, int $flags, bool $matchesAll): Type
348+
private function createSubjectValueType(Type $baseType, int $flags, bool $matchesAll, TrinaryLogic $wasMatched): Type
343349
{
344350
$subjectValueType = TypeCombinator::removeNull($this->getValueType($baseType, $flags, $matchesAll));
345351

346352
if ($matchesAll) {
347353
$subjectValueType = TypeCombinator::removeNull($this->getValueType(new StringType(), $flags, $matchesAll));
348354

349355
if ($this->containsPatternOrder($flags)) {
350-
$subjectValueType = new IntersectionType([
356+
$accessoryTypes = [
351357
new ArrayType(new IntegerType(), $subjectValueType),
352358
new AccessoryArrayListType(),
353-
]);
359+
];
360+
if ($wasMatched->yes()) {
361+
$accessoryTypes[] = new NonEmptyArrayType();
362+
}
363+
364+
$subjectValueType = new IntersectionType($accessoryTypes);
354365
}
355366
}
356367

@@ -414,7 +425,12 @@ private function createGroupValueType(RegexCapturingGroup $captureGroup, Trinary
414425
}
415426

416427
if ($this->containsPatternOrder($flags)) {
417-
$groupValueType = new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $groupValueType), new AccessoryArrayListType()]);
428+
$accessoryTypes = [new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $groupValueType), new AccessoryArrayListType()];
429+
if ($wasMatched->yes()) {
430+
$accessoryTypes[] = new NonEmptyArrayType();
431+
}
432+
433+
$groupValueType = new IntersectionType($accessoryTypes);
418434
}
419435

420436
return $groupValueType;

tests/PHPStan/Analyser/nsrt/preg_match_all_shapes.php

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ function (string $size): void {
2626

2727
function (string $size): void {
2828
if (preg_match_all('/ab(?P<num>\d+)?/', $size, $matches)) {
29-
assertType("array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
29+
assertType("array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
3030
} else {
3131
assertType("array{}", $matches);
3232
}
33-
assertType("array{}|array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
33+
assertType("array{}|array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
3434
};
3535

3636
function (string $size): void {
@@ -44,20 +44,20 @@ function (string $size): void {
4444

4545
function (string $size): void {
4646
if (preg_match_all('/ab(?P<num>\d+)?/', $size, $matches) != false) {
47-
assertType("array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
47+
assertType("array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
4848
} else {
4949
assertType("array{}", $matches);
5050
}
51-
assertType("array{}|array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
51+
assertType("array{}|array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
5252
};
5353

5454
function (string $size): void {
5555
if (preg_match_all('/ab(?P<num>\d+)?/', $size, $matches) == true) {
56-
assertType("array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
56+
assertType("array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
5757
} else {
5858
assertType("array{}", $matches);
5959
}
60-
assertType("array{}|array{0: list<string>, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches);
60+
assertType("array{}|array{0: non-empty-list<string>, num: non-empty-list<''|decimal-int-string>, 1: non-empty-list<''|decimal-int-string>}", $matches);
6161
};
6262

6363
function (string $size): void {
@@ -76,61 +76,61 @@ function (string $size): void {
7676

7777
function (string $size): void {
7878
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches)) {
79-
assertType("array{0: list<string>, num: list<decimal-int-string>, 1: list<decimal-int-string>, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches);
79+
assertType("array{0: non-empty-list<string>, num: non-empty-list<decimal-int-string>, 1: non-empty-list<decimal-int-string>, suffix: non-empty-list<''|'ab'>, 2: non-empty-list<''|'ab'>}", $matches);
8080
}
8181
};
8282

8383
function (string $size): void {
8484
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL)) {
85-
assertType("array{0: list<string>, num: list<decimal-int-string>, 1: list<decimal-int-string>, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches);
85+
assertType("array{0: non-empty-list<string>, num: non-empty-list<decimal-int-string>, 1: non-empty-list<decimal-int-string>, suffix: non-empty-list<'ab'|null>, 2: non-empty-list<'ab'|null>}", $matches);
8686
}
8787
};
8888

8989
function (string $size): void {
9090
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_SET_ORDER)) {
91-
assertType("list<array{0: string, num: decimal-int-string, 1: decimal-int-string, suffix?: 'ab', 2?: 'ab'}>", $matches);
91+
assertType("non-empty-list<array{0: string, num: decimal-int-string, 1: decimal-int-string, suffix?: 'ab', 2?: 'ab'}>", $matches);
9292
}
9393
};
9494

9595
function (string $size): void {
9696
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_PATTERN_ORDER)) {
97-
assertType("array{0: list<string>, num: list<decimal-int-string>, 1: list<decimal-int-string>, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches);
97+
assertType("array{0: non-empty-list<string>, num: non-empty-list<decimal-int-string>, 1: non-empty-list<decimal-int-string>, suffix: non-empty-list<''|'ab'>, 2: non-empty-list<''|'ab'>}", $matches);
9898
}
9999
};
100100

101101
function (string $size): void {
102102
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER)) {
103-
assertType("list<array{0: string, num: decimal-int-string, 1: decimal-int-string, suffix: 'ab'|null, 2: 'ab'|null}>", $matches);
103+
assertType("non-empty-list<array{0: string, num: decimal-int-string, 1: decimal-int-string, suffix: 'ab'|null, 2: 'ab'|null}>", $matches);
104104
}
105105
};
106106

107107
function (string $size): void {
108108
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER)) {
109-
assertType("array{0: list<string>, num: list<decimal-int-string>, 1: list<decimal-int-string>, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches);
109+
assertType("array{0: non-empty-list<string>, num: non-empty-list<decimal-int-string>, 1: non-empty-list<decimal-int-string>, suffix: non-empty-list<'ab'|null>, 2: non-empty-list<'ab'|null>}", $matches);
110110
}
111111
};
112112

113113
function (string $size): void {
114114
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) {
115-
assertType("list<array{0: array{string, int<-1, max>}, num: array{decimal-int-string, int<-1, max>}, 1: array{decimal-int-string, int<-1, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches);
115+
assertType("non-empty-list<array{0: array{string, int<-1, max>}, num: array{decimal-int-string, int<-1, max>}, 1: array{decimal-int-string, int<-1, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches);
116116
}
117117
};
118118

119119
function (string $size): void {
120120
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) {
121-
assertType("array{0: list<array{string, int<-1, max>}>, num: list<array{decimal-int-string, int<-1, max>}>, 1: list<array{decimal-int-string, int<-1, max>}>, suffix: list<array{''|'ab', int<-1, max>}>, 2: list<array{''|'ab', int<-1, max>}>}", $matches);
121+
assertType("array{0: non-empty-list<array{string, int<-1, max>}>, num: non-empty-list<array{decimal-int-string, int<-1, max>}>, 1: non-empty-list<array{decimal-int-string, int<-1, max>}>, suffix: non-empty-list<array{''|'ab', int<-1, max>}>, 2: non-empty-list<array{''|'ab', int<-1, max>}>}", $matches);
122122
}
123123
};
124124

125125
function (string $size): void {
126126
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) {
127-
assertType("list<array{0: array{string|null, int<-1, max>}, num: array{decimal-int-string|null, int<-1, max>}, 1: array{decimal-int-string|null, int<-1, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches);
127+
assertType("non-empty-list<array{0: array{string|null, int<-1, max>}, num: array{decimal-int-string|null, int<-1, max>}, 1: array{decimal-int-string|null, int<-1, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches);
128128
}
129129
};
130130

131131
function (string $size): void {
132132
if (preg_match_all('/ab(?P<num>\d+)(?P<suffix>ab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) {
133-
assertType("array{0: list<array{string|null, int<-1, max>}>, num: list<array{decimal-int-string|null, int<-1, max>}>, 1: list<array{decimal-int-string|null, int<-1, max>}>, suffix: list<array{'ab'|null, int<-1, max>}>, 2: list<array{'ab'|null, int<-1, max>}>}", $matches);
133+
assertType("array{0: non-empty-list<array{string|null, int<-1, max>}>, num: non-empty-list<array{decimal-int-string|null, int<-1, max>}>, 1: non-empty-list<array{decimal-int-string|null, int<-1, max>}>, suffix: non-empty-list<array{'ab'|null, int<-1, max>}>, 2: non-empty-list<array{'ab'|null, int<-1, max>}>}", $matches);
134134
}
135135
};
136136

@@ -165,17 +165,31 @@ public function sayBar(string $content): void
165165

166166
function doFoobar(string $s): void {
167167
if (preg_match_all('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_OFFSET_CAPTURE)) {
168-
assertType("array{list<array{string, int<-1, max>}>, list<array{''|'foo', int<-1, max>}>, list<array{''|'bar', int<-1, max>}>, list<array{''|'baz', int<-1, max>}>}", $matches);
168+
assertType("array{non-empty-list<array{string, int<-1, max>}>, non-empty-list<array{''|'foo', int<-1, max>}>, non-empty-list<array{''|'bar', int<-1, max>}>, non-empty-list<array{''|'baz', int<-1, max>}>}", $matches);
169169
}
170170
}
171171

172172
function doFoobarNull(string $s): void {
173173
if (preg_match_all('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL)) {
174-
assertType("array{list<array{string|null, int<-1, max>}>, list<array{'foo'|null, int<-1, max>}>, list<array{'bar'|null, int<-1, max>}>, list<array{'baz'|null, int<-1, max>}>}", $matches);
174+
assertType("array{non-empty-list<array{string|null, int<-1, max>}>, non-empty-list<array{'foo'|null, int<-1, max>}>, non-empty-list<array{'bar'|null, int<-1, max>}>, non-empty-list<array{'baz'|null, int<-1, max>}>}", $matches);
175175
}
176176
}
177177
}
178178

179+
function bug14781(string $s): void {
180+
if (preg_match_all('/(\d+)/', $s, $matches)) {
181+
assertType('array{non-empty-list<string>, non-empty-list<decimal-int-string>}', $matches);
182+
// accessing offset 0 is safe because the lists are non-empty
183+
assertType('string', $matches[0][0]);
184+
assertType('decimal-int-string', $matches[1][0]);
185+
}
186+
187+
if (preg_match_all('/(\d+)/', $s, $setMatches, PREG_SET_ORDER)) {
188+
assertType('non-empty-list<array{string, decimal-int-string}>', $setMatches);
189+
assertType('array{string, decimal-int-string}', $setMatches[0]);
190+
}
191+
}
192+
179193
function bug11661(): void {
180194
preg_match_all('/(ERR)?(.+)/', 'abc', $results, PREG_SET_ORDER);
181195
assertType("list<array{string, ''|'ERR', non-empty-string}>", $results);

0 commit comments

Comments
 (0)