1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Type\Php;
4:
5: use PhpParser\Node\Expr;
6: use PHPStan\Analyser\Scope;
7: use PHPStan\DependencyInjection\AutowiredService;
8: use PHPStan\Php\PhpVersion;
9: use PHPStan\TrinaryLogic;
10: use PHPStan\Type\Accessory\AccessoryArrayListType;
11: use PHPStan\Type\Accessory\NonEmptyArrayType;
12: use PHPStan\Type\ArrayType;
13: use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
14: use PHPStan\Type\Constant\ConstantIntegerType;
15: use PHPStan\Type\Constant\ConstantStringType;
16: use PHPStan\Type\IntegerRangeType;
17: use PHPStan\Type\IntegerType;
18: use PHPStan\Type\IntersectionType;
19: use PHPStan\Type\NullType;
20: use PHPStan\Type\Regex\RegexCapturingGroup;
21: use PHPStan\Type\Regex\RegexExpressionHelper;
22: use PHPStan\Type\Regex\RegexGroupList;
23: use PHPStan\Type\Regex\RegexGroupParser;
24: use PHPStan\Type\StringType;
25: use PHPStan\Type\Type;
26: use PHPStan\Type\TypeCombinator;
27: use function count;
28: use function in_array;
29: use function is_string;
30: use const PREG_OFFSET_CAPTURE;
31: use const PREG_PATTERN_ORDER;
32: use const PREG_SET_ORDER;
33: use const PREG_UNMATCHED_AS_NULL;
34:
35: /**
36: * @api
37: */
38: #[AutowiredService]
39: final class RegexArrayShapeMatcher
40: {
41:
42: /**
43: * Pass this into $flagsType as well if the library supports emulating PREG_UNMATCHED_AS_NULL on PHP 7.2 and 7.3
44: */
45: public const PREG_UNMATCHED_AS_NULL_ON_72_73 = 2048;
46:
47: public function __construct(
48: private RegexGroupParser $regexGroupParser,
49: private RegexExpressionHelper $regexExpressionHelper,
50: private PhpVersion $phpVersion,
51: )
52: {
53: }
54:
55: public function matchAllExpr(Expr $patternExpr, ?Type $flagsType, TrinaryLogic $wasMatched, Scope $scope): ?Type
56: {
57: return $this->matchPatternType($this->getPatternType($patternExpr, $scope), $flagsType, $wasMatched, true);
58: }
59:
60: public function matchExpr(Expr $patternExpr, ?Type $flagsType, TrinaryLogic $wasMatched, Scope $scope): ?Type
61: {
62: return $this->matchPatternType($this->getPatternType($patternExpr, $scope), $flagsType, $wasMatched, false);
63: }
64:
65: public function matchSubjectExpr(Expr $patternExpr, Scope $scope): ?Type
66: {
67: $patternType = $this->getPatternType($patternExpr, $scope);
68: $constantStrings = $patternType->getConstantStrings();
69: if (count($constantStrings) === 0) {
70: return null;
71: }
72:
73: $subjectTypes = [];
74: foreach ($constantStrings as $constantString) {
75: $astWalkResult = $this->regexGroupParser->parseGroups($constantString->getValue());
76: if ($astWalkResult === null) {
77: return null;
78: }
79:
80: $subjectTypes[] = $astWalkResult->getSubjectBaseType();
81: }
82:
83: return TypeCombinator::union(...$subjectTypes);
84: }
85:
86: private function matchPatternType(Type $patternType, ?Type $flagsType, TrinaryLogic $wasMatched, bool $matchesAll): ?Type
87: {
88: if ($wasMatched->no()) {
89: return ConstantArrayTypeBuilder::createEmpty()->getArray();
90: }
91:
92: $constantStrings = $patternType->getConstantStrings();
93: if (count($constantStrings) === 0) {
94: return null;
95: }
96:
97: $flags = null;
98: if ($flagsType !== null) {
99: if (!$flagsType instanceof ConstantIntegerType) {
100: return null;
101: }
102:
103: /** @var int-mask<PREG_OFFSET_CAPTURE | PREG_PATTERN_ORDER | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL | self::PREG_UNMATCHED_AS_NULL_ON_72_73> $flags */
104: $flags = $flagsType->getValue() & (PREG_OFFSET_CAPTURE | PREG_PATTERN_ORDER | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL | self::PREG_UNMATCHED_AS_NULL_ON_72_73);
105:
106: // some other unsupported/unexpected flag was passed in
107: if ($flags !== $flagsType->getValue()) {
108: return null;
109: }
110: }
111:
112: $matchedTypes = [];
113: foreach ($constantStrings as $constantString) {
114: $matched = $this->matchRegex($constantString->getValue(), $flags, $wasMatched, $matchesAll);
115: if ($matched === null) {
116: return null;
117: }
118:
119: $matchedTypes[] = $matched;
120: }
121:
122: if (count($matchedTypes) === 1) {
123: return $matchedTypes[0];
124: }
125:
126: return TypeCombinator::union(...$matchedTypes);
127: }
128:
129: /**
130: * @param int-mask<PREG_OFFSET_CAPTURE|PREG_PATTERN_ORDER|PREG_SET_ORDER|PREG_UNMATCHED_AS_NULL|self::PREG_UNMATCHED_AS_NULL_ON_72_73>|null $flags
131: */
132: private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched, bool $matchesAll): ?Type
133: {
134: $astWalkResult = $this->regexGroupParser->parseGroups($regex);
135: if ($astWalkResult === null) {
136: // regex could not be parsed by RegexParser
137: return null;
138: }
139: $groupList = $astWalkResult->getCapturingGroups();
140: $markVerbs = $astWalkResult->getMarkVerbs();
141: $subjectBaseType = new StringType();
142: if ($wasMatched->yes()) {
143: $subjectBaseType = $astWalkResult->getSubjectBaseType();
144: }
145:
146: $regexGroupList = new RegexGroupList($groupList);
147: $trailingOptionals = $regexGroupList->countTrailingOptionals();
148: $onlyOptionalTopLevelGroup = $regexGroupList->getOnlyOptionalTopLevelGroup();
149: $onlyTopLevelAlternation = $regexGroupList->getOnlyTopLevelAlternation();
150: $flags ??= 0;
151:
152: if ($regexGroupList->hasDuplicateNamesOutsideOfAlternationBranches(
153: !$matchesAll && $onlyOptionalTopLevelGroup === null && !$this->containsUnmatchedAsNull($flags, $matchesAll)
154: ? $onlyTopLevelAlternation
155: : null,
156: )) {
157: // which of the groups sharing a name sets the named offset is only modelled
158: // for groups in different branches of a top level alternation
159: return null;
160: }
161:
162: if (
163: $regexGroupList->hasGroupResettingGroupCounter()
164: && (
165: $matchesAll
166: || $onlyOptionalTopLevelGroup !== null
167: || $onlyTopLevelAlternation === null
168: || $this->containsUnmatchedAsNull($flags, $matchesAll)
169: )
170: ) {
171: // groups in a branch reset (?|...) share offsets, which is only modelled
172: // when its alternation is the only top level alternation
173: return null;
174: }
175:
176: if (
177: !$matchesAll
178: && $wasMatched->yes()
179: && $onlyOptionalTopLevelGroup !== null
180: ) {
181: // if only one top level capturing optional group exists
182: // we build a more precise tagged union of a empty-match and a match with the group
183: $regexGroupList = $regexGroupList->forceGroupNonOptional($onlyOptionalTopLevelGroup);
184:
185: $combiType = $this->buildArrayType(
186: $subjectBaseType,
187: $regexGroupList,
188: $wasMatched,
189: $trailingOptionals,
190: $flags,
191: $markVerbs,
192: $matchesAll,
193: );
194:
195: if (!$this->containsUnmatchedAsNull($flags, $matchesAll)) {
196: // positive match has a subject but not any capturing group
197: $builder = ConstantArrayTypeBuilder::createEmpty();
198: $builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched));
199:
200: $combiType = TypeCombinator::union(
201: $builder->getArray(),
202: $combiType,
203: );
204: }
205:
206: return $combiType;
207: } elseif (
208: !$matchesAll
209: && $onlyOptionalTopLevelGroup === null
210: && $onlyTopLevelAlternation !== null
211: && !$wasMatched->no()
212: ) {
213: // if only a single top level alternation exist built a more precise tagged union
214:
215: $combiTypes = [];
216: $isOptionalAlternation = false;
217: foreach ($onlyTopLevelAlternation->getGroupCombinations() as $groupCombo) {
218: $comboList = new RegexGroupList($groupList);
219:
220: $beforeCurrentCombo = true;
221: foreach ($comboList as $group) {
222: if (in_array($group->getId(), $groupCombo, true)) {
223: $isOptionalAlternation = $group->inOptionalAlternation();
224: $comboList = $comboList->forceGroupAlternationBranchMatched($group);
225: $beforeCurrentCombo = false;
226: } elseif ($beforeCurrentCombo && !$group->resetsGroupCounter()) {
227: $comboList = $comboList->forceGroupTypeAndNonOptional(
228: $group,
229: $this->getUnmatchedGroupType($flags),
230: );
231: } elseif (
232: $group->getAlternationId() === $onlyTopLevelAlternation->getId()
233: && !$this->containsUnmatchedAsNull($flags, $matchesAll)
234: ) {
235: $comboList = $comboList->removeGroup($group);
236: }
237: }
238:
239: $combiType = $this->buildArrayType(
240: $subjectBaseType,
241: $comboList,
242: $wasMatched,
243: $trailingOptionals,
244: $flags,
245: $markVerbs,
246: $matchesAll,
247: );
248:
249: $combiTypes[] = $combiType;
250: }
251:
252: if (
253: !$this->containsUnmatchedAsNull($flags, $matchesAll)
254: && (
255: $onlyTopLevelAlternation->getAlternationsCount() !== count($onlyTopLevelAlternation->getGroupCombinations())
256: || $isOptionalAlternation
257: )
258: ) {
259: // positive match has a subject but not any capturing group
260: $builder = ConstantArrayTypeBuilder::createEmpty();
261: $builder->setOffsetValueType(new ConstantIntegerType(0), $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched));
262:
263: $combiTypes[] = $builder->getArray();
264: }
265:
266: return TypeCombinator::union(...$combiTypes);
267: }
268:
269: // the general case, which should work in all cases but does not yield the most
270: // precise result possible in some cases
271: return $this->buildArrayType(
272: $subjectBaseType,
273: $regexGroupList,
274: $wasMatched,
275: $trailingOptionals,
276: $flags,
277: $markVerbs,
278: $matchesAll,
279: );
280: }
281:
282: /**
283: * @param list<string> $markVerbs
284: */
285: private function buildArrayType(
286: Type $subjectBaseType,
287: RegexGroupList $captureGroups,
288: TrinaryLogic $wasMatched,
289: int $trailingOptionals,
290: int $flags,
291: array $markVerbs,
292: bool $matchesAll,
293: ): Type
294: {
295: $forceList = count($markVerbs) === 0;
296: $builder = ConstantArrayTypeBuilder::createEmpty();
297:
298: // first item in matches contains the overall match.
299: $builder->setOffsetValueType(
300: $this->getKeyType(0),
301: $this->createSubjectValueType($subjectBaseType, $flags, $matchesAll, $wasMatched),
302: $this->isSubjectOptional($wasMatched, $matchesAll),
303: );
304:
305: $countGroups = count($captureGroups);
306: $i = 0;
307: foreach ($captureGroups as $captureGroup) {
308: $isTrailingOptional = $i >= $countGroups - $trailingOptionals;
309: $isLastGroup = $i === $countGroups - 1;
310: $groupValueType = $this->createGroupValueType($captureGroup, $wasMatched, $flags, $isTrailingOptional, $isLastGroup, $matchesAll);
311: $optional = $this->isGroupOptional($captureGroup, $wasMatched, $flags, $isTrailingOptional, $matchesAll);
312:
313: if ($captureGroup->isNamed()) {
314: $forceList = false;
315:
316: $builder->setOffsetValueType(
317: $this->getKeyType($captureGroup->getName()),
318: $groupValueType,
319: $optional,
320: );
321: }
322:
323: $builder->setOffsetValueType(
324: $this->getKeyType($i + 1),
325: $groupValueType,
326: $optional,
327: );
328:
329: $i++;
330: }
331:
332: if (count($markVerbs) > 0) {
333: $markTypes = [];
334: foreach ($markVerbs as $mark) {
335: $markTypes[] = new ConstantStringType($mark);
336: }
337: $markType = TypeCombinator::union(...$markTypes);
338: if ($matchesAll && $this->containsPatternOrder($flags)) {
339: // marks are keyed by the index of the match that set them, matches without a mark are left out
340: $markType = new IntersectionType([
341: new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $markType),
342: new NonEmptyArrayType(),
343: ]);
344: }
345:
346: $builder->setOffsetValueType(
347: $this->getKeyType('MARK'),
348: $markType,
349: true,
350: );
351: }
352:
353: if ($matchesAll && $this->containsSetOrder($flags)) {
354: $accessoryTypes = [new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $builder->getArray()), new AccessoryArrayListType()];
355: if ($wasMatched->yes()) {
356: $accessoryTypes[] = new NonEmptyArrayType();
357: }
358:
359: return new IntersectionType($accessoryTypes);
360: }
361:
362: if ($forceList) {
363: return TypeCombinator::intersect($builder->getArray(), new AccessoryArrayListType());
364: }
365:
366: return $builder->getArray();
367: }
368:
369: private function isSubjectOptional(TrinaryLogic $wasMatched, bool $matchesAll): bool
370: {
371: if ($matchesAll) {
372: return false;
373: }
374:
375: return !$wasMatched->yes();
376: }
377:
378: /**
379: * @param Type $baseType A string type (or string variant) representing the subject of the match
380: */
381: private function createSubjectValueType(Type $baseType, int $flags, bool $matchesAll, TrinaryLogic $wasMatched): Type
382: {
383: $subjectValueType = TypeCombinator::removeNull($this->getValueType($baseType, $flags, $matchesAll));
384:
385: if ($matchesAll) {
386: $subjectValueType = TypeCombinator::removeNull($this->getValueType(new StringType(), $flags, $matchesAll));
387:
388: if ($this->containsPatternOrder($flags)) {
389: $accessoryTypes = [
390: new ArrayType(new IntegerType(), $subjectValueType),
391: new AccessoryArrayListType(),
392: ];
393: if ($wasMatched->yes()) {
394: $accessoryTypes[] = new NonEmptyArrayType();
395: }
396:
397: $subjectValueType = new IntersectionType($accessoryTypes);
398: }
399: }
400:
401: return $subjectValueType;
402: }
403:
404: private function isGroupOptional(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $matchesAll): bool
405: {
406: if ($matchesAll) {
407: if ($isTrailingOptional && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $this->containsSetOrder($flags)) {
408: return true;
409: }
410:
411: return false;
412: }
413:
414: if (!$wasMatched->yes()) {
415: $optional = true;
416: } else {
417: if (!$isTrailingOptional) {
418: $optional = false;
419: } elseif ($this->containsUnmatchedAsNull($flags, $matchesAll)) {
420: $optional = false;
421: } else {
422: $optional = $captureGroup->isOptional();
423: }
424: }
425:
426: return $optional;
427: }
428:
429: private function createGroupValueType(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $isLastGroup, bool $matchesAll): Type
430: {
431: if ($matchesAll) {
432: if (
433: (
434: !$this->containsSetOrder($flags)
435: && !$this->containsUnmatchedAsNull($flags, $matchesAll)
436: && $captureGroup->isOptional()
437: )
438: ||
439: (
440: $this->containsSetOrder($flags)
441: && !$this->containsUnmatchedAsNull($flags, $matchesAll)
442: && $captureGroup->isOptional()
443: // an unmatched group followed by a matched one is reported as ''
444: && !$isLastGroup
445: )
446: ) {
447: $groupValueType = $this->getValueType(
448: TypeCombinator::union($captureGroup->getType(), new ConstantStringType('')),
449: $flags,
450: $matchesAll,
451: );
452: $groupValueType = TypeCombinator::removeNull($groupValueType);
453: } else {
454: $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll);
455: }
456:
457: if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) {
458: $groupValueType = TypeCombinator::removeNull($groupValueType);
459: }
460:
461: if ($this->containsPatternOrder($flags)) {
462: $accessoryTypes = [new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $groupValueType), new AccessoryArrayListType()];
463: if ($wasMatched->yes()) {
464: $accessoryTypes[] = new NonEmptyArrayType();
465: }
466:
467: $groupValueType = new IntersectionType($accessoryTypes);
468: }
469:
470: return $groupValueType;
471: }
472:
473: if (!$isLastGroup && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $captureGroup->isOptional()) {
474: $groupValueType = $this->getValueType(
475: TypeCombinator::union($captureGroup->getType(), $this->getUnmatchedGroupType($flags)),
476: $flags,
477: $matchesAll,
478: );
479: } else {
480: $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll);
481: }
482:
483: if ($wasMatched->yes()) {
484: if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) {
485: $groupValueType = TypeCombinator::removeNull($groupValueType);
486: }
487: }
488:
489: return $groupValueType;
490: }
491:
492: private function containsOffsetCapture(int $flags): bool
493: {
494: return ($flags & PREG_OFFSET_CAPTURE) !== 0;
495: }
496:
497: private function containsPatternOrder(int $flags): bool
498: {
499: // If no order flag is given, PREG_PATTERN_ORDER is assumed.
500: return !$this->containsSetOrder($flags);
501: }
502:
503: private function containsSetOrder(int $flags): bool
504: {
505: return ($flags & PREG_SET_ORDER) !== 0;
506: }
507:
508: private function containsUnmatchedAsNull(int $flags, bool $matchesAll): bool
509: {
510: if ($matchesAll) {
511: // preg_match_all() with PREG_UNMATCHED_AS_NULL works consistently across php-versions
512: // https://3v4l.org/tKmPn
513: return ($flags & PREG_UNMATCHED_AS_NULL) !== 0;
514: }
515:
516: return ($flags & PREG_UNMATCHED_AS_NULL) !== 0 && (($flags & self::PREG_UNMATCHED_AS_NULL_ON_72_73) !== 0 || $this->phpVersion->supportsPregUnmatchedAsNull());
517: }
518:
519: /**
520: * Type of an unmatched group followed by a matched one.
521: *
522: * PHP 7.2 and 7.3 already report it as null with PREG_UNMATCHED_AS_NULL,
523: * only the trailing unmatched groups are omitted there.
524: */
525: private function getUnmatchedGroupType(int $flags): Type
526: {
527: if (($flags & PREG_UNMATCHED_AS_NULL) !== 0) {
528: return new NullType();
529: }
530:
531: return new ConstantStringType('');
532: }
533:
534: private function getKeyType(int|string $key): Type
535: {
536: if (is_string($key)) {
537: return new ConstantStringType($key);
538: }
539:
540: return new ConstantIntegerType($key);
541: }
542:
543: private function getValueType(Type $baseType, int $flags, bool $matchesAll): Type
544: {
545: $valueType = $baseType;
546:
547: // unmatched groups return -1 as offset
548: $offsetType = IntegerRangeType::fromInterval(-1, null);
549: if ($this->containsUnmatchedAsNull($flags, $matchesAll)) {
550: $valueType = TypeCombinator::addNull($valueType);
551: }
552:
553: if ($this->containsOffsetCapture($flags)) {
554: $builder = ConstantArrayTypeBuilder::createEmpty();
555:
556: $builder->setOffsetValueType(
557: new ConstantIntegerType(0),
558: $valueType,
559: );
560: $builder->setOffsetValueType(
561: new ConstantIntegerType(1),
562: $offsetType,
563: );
564:
565: return $builder->getArray();
566: }
567:
568: return $valueType;
569: }
570:
571: private function getPatternType(Expr $patternExpr, Scope $scope): Type
572: {
573: if ($patternExpr instanceof Expr\BinaryOp\Concat) {
574: return $this->regexExpressionHelper->resolvePatternConcat($patternExpr, $scope);
575: }
576:
577: return $scope->getType($patternExpr);
578: }
579:
580: }
581: